Compare commits

..

22 Commits

Author SHA1 Message Date
lorenzejay
3855076670 self improve with skills 2026-05-05 14:45:52 -07:00
lorenzejay
4af40c64f2 self improve with skills 2026-05-05 14:42:25 -07:00
Greyson LaLonde
e25f6538a8 fix(deps): bump gitpython to >=3.1.47 for GHSA-rpm5-65cw-6hj4
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
2026-05-04 23:44:28 +08:00
Greyson LaLonde
470d4035db docs: update changelog and version for v1.14.5a2 2026-05-04 23:04:56 +08:00
Greyson LaLonde
57d1b338f7 feat: bump versions to 1.14.5a2 2026-05-04 22:58:06 +08:00
huang yutong
01df19b029 fix(a2a): always restore task.output_pydantic in finally block
In `_execute_task_with_a2a` and its async variant, the try body
sets `task.output_pydantic = None` before returning an A2A
response. The finally block then checks
`if task.output_pydantic is not None` before restoring the
original value — but since it was just set to None, the condition
is always False and the original value is never restored. This
permanently mutates the Task object.

Remove the guard so `output_pydantic` is unconditionally restored,
matching the unconditional restoration of `description` and
`response_model` in the same block.

Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
2026-05-04 22:41:04 +08:00
Rip&Tear
dca2c3160f chore: update security reporting instructions
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
2026-05-04 22:31:35 +08:00
Greyson LaLonde
6494d68ffc fix(gemini): include thoughts_token_count in completion tokens 2026-05-04 21:03:38 +08:00
Greyson LaLonde
f579aa53ae fix: preserve task outputs across async batch flush 2026-05-04 20:24:24 +08:00
minasami-pr
a23e118b11 fix: forward kwargs to loader calls in CrewAIRagAdapter
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
2026-05-04 19:52:24 +08:00
Greyson LaLonde
095f796922 fix: prevent result_as_answer from returning hook-block message as final answer 2026-05-04 19:42:07 +08:00
Zamuldinov Nikita
bfbdba426f fix: prevent result_as_answer from returning error as final answer
When a tool with result_as_answer=True raises an exception, the agent
was receiving result_as_answer=True and returning the error string as
the final answer. Now we set result_as_answer=False when an error event
is emitted, allowing the agent to reflect and retry.

Fixes crewAIInc/crewAI#5156

---------

Co-authored-by: NIK-TIGER-BILL <nik.tiger.bill@github.com>
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
2026-05-04 19:28:21 +08:00
Greyson LaLonde
a058a3b15b fix(task): use acall for output conversion in async paths 2026-05-04 18:42:12 +08:00
Greyson LaLonde
184c228ae9 fix: prevent shared LLM stop words mutation across agents
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-05-04 14:23:17 +08:00
Greyson LaLonde
c9100cb51d docs(devtools): document additional env vars
Some checks failed
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
2026-05-03 14:50:44 +08:00
Greyson LaLonde
17e82743f6 fix: handle BaseModel input in convert_to_model 2026-05-03 14:17:03 +08:00
Lorenze Jay
3403f3cba9 docs: update changelog and version for v1.14.5a1 (#5678)
Some checks failed
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-05-01 14:27:57 -07:00
Lorenze Jay
5db72250b2 feat: bump versions to 1.14.5a1 (#5677)
* feat: bump versions to 1.14.5a1

* chore: update tool specifications

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 14:21:50 -07:00
Greyson LaLonde
a071838e92 fix(devtools): cover missing crewai pin sites in release flow 2026-05-02 03:26:56 +08:00
Tiago Freire
cd2b9ee38a feat(flow): add restore_from_state_id kickoff parameter (#5674)
## Summary

- Reverts `b0e2fda` ("fix(flow): add execution_id separate from state.id", COR-48): removes `Flow.execution_id` and points `current_flow_id` / `current_flow_request_id` back at `flow_id` (i.e. `state.id`). The separate per-run tracking id was no longer the right abstraction once `restore_from_state_id` reshapes how `state.id` is assigned;

- Adds an optional `restore_from_state_id` kwarg to `Flow.kickoff` / `Flow.kickoff_async` that hydrates state from a previously-persisted flow's latest snapshot

- Reassigns `state.id` to a fresh value (or `inputs["id"]` if pinned) so the new run's `@persist` writes don't extend the source's history

- Existing `inputs["id"]` resume, `@persist`, and `from_checkpoint` paths are unchanged

## Problem
`@persist` only supports *resume* today: `kickoff(inputs={"id": <uuid>})` hydrates state and continues writing under the same `flow_uuid`. There's no way to **fork** — hydrate from a snapshot but persist under a separate key, leaving the source's history intact. This PR adds that.

| | `state.id` after kickoff | `@persist` writes land under |
|---|---|---|
| `inputs["id"]` (resume) | supplied id | supplied id (extends history) |
| `restore_from_state_id` (fork) | fresh id, or `inputs["id"]` if pinned | new id (source preserved) |

## Behavior

| `inputs.id` | `restore_from_state_id` | Effect |
|---|---|---|
| — | — | Fresh kickoff |
| set | — | Existing resume |
| — | UUID | Fork — new `state.id`, hydrated from source |
| set | UUID | Fork into a pinned `state.id`, hydrated from source |

- Source not found → silent fallback (mirrors existing resume)
- Both `from_checkpoint` and `restore_from_state_id` set → `ValueError`
- `restore_from_state_id=None` → byte-identical to current main

## Design
Fork hydration runs before the existing `inputs` block in `kickoff_async`. On a hit, it calls the same `_restore_state` primitive used by resume, then overwrites `state.id` with a fresh UUID (or `inputs["id"]`). A `fork_succeeded` flag gates the existing `inputs["id"]` path so we don't double-load. `_completed_methods` / `_is_execution_resuming` are intentionally untouched — skip-completed-methods remains the territory of `apply_checkpoint` and `from_pending`.

## Test plan
- [ ] `pytest tests/test_flow_persistence.py` — 5 new tests (four-row matrix, not-found fallback, default no-op, conflict raise) + 6 existing as regression
- [ ] `pytest tests/test_flow.py` — broader flow suite
- [ ] Manual end-to-end against an HITL `@persist` flow
2026-05-01 11:46:07 -04:00
Ishan Goswami
07c4a30f2e feat(crewai-tools): add highlights to ExaSearchTool, rename from EXASearchTool
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
* feat(crewai-tools): add highlights to ExaSearchTool, rename from EXASearchTool

- Add a highlights init param so agents can get token-efficient excerpts instead of full pages
- Rename EXASearchTool to ExaSearchTool; keep EXASearchTool as a deprecated alias so existing imports keep working
- Update the docs and example to use highlights as the recommended option
- Add a small note that says Exa is the fastest and most accurate web search API
- Add tests for the new highlights param and the deprecation alias

* fix(crewai-tools): import order and module-level Exa for tests

- Reorder std-lib imports so ruff is happy with force-sort-within-sections.
- Import Exa at module level (with a fallback) so the existing test mocks resolve.
  The lazy install prompt still works if exa_py is missing.
- Allow content and summary to be a dict, matching highlights.
- Trim test file to the cases this PR introduces (highlights param and the
  EXASearchTool deprecation alias). Existing init-shape tests stay.

Co-Authored-By: ishan <ishan@exa.ai>

* chore(crewai-tools): drop self-explanatory comment on schema alias

Co-Authored-By: ishan <ishan@exa.ai>

* docs(crewai-tools): default highlights to True, drop summary from examples

Co-Authored-By: ishan <ishan@exa.ai>

* docs(crewai-tools): simplify highlights examples to highlights=True

Co-Authored-By: ishan <ishan@exa.ai>

* feat(crewai-tools): add x-exa-integration header for usage tracking

Co-Authored-By: ishan <ishan@exa.ai>

* docs(crewai-tools): add Exa MCP section and resources links

Co-Authored-By: ishan <ishan@exa.ai>

---------

Co-authored-by: ishan <ishan@exa.ai>
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-05-01 21:25:23 +08:00
Lorenze Jay
b30fdbaa0e fix: ensure skills loading events for traces
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-05-01 12:08:25 +08:00
86 changed files with 5042 additions and 523 deletions

5
.github/security.md vendored
View File

@@ -5,7 +5,10 @@ CrewAI ecosystem.
### How to Report
Please submit reports to **crewai-vdp-ess@submit.bugcrowd.com**
Please submit reports through one of the following channels:
- **crewai-vdp-ess@submit.bugcrowd.com**
- https://security.crewai.com
- **Please do not** disclose vulnerabilities via public GitHub issues, pull requests,
or social media

View File

@@ -4,6 +4,58 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="4 مايو 2026">
## v1.14.5a2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a2)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح استعادة مخرجات المهام في كتلة finally
- تضمين `thoughts_token_count` في رموز الإكمال
- الحفاظ على مخرجات المهام عبر تفريغ دفعات غير متزامنة
- تمرير kwargs إلى استدعاءات المحمل في `CrewAIRagAdapter`
- منع `result_as_answer` من إرجاع رسالة كتلة الخطاف كإجابة نهائية
- منع `result_as_answer` من إرجاع خطأ كإجابة نهائية
- استخدام `acall` لتحويل المخرجات في المسارات غير المتزامنة
- منع تغيير كلمات التوقف المشتركة في LLM عبر الوكلاء
- التعامل مع مدخلات `BaseModel` في `convert_to_model`
### الوثائق
- توثيق متغيرات البيئة الإضافية
- تحديث سجل التغييرات والإصدار لـ v1.14.5a1
## المساهمون
@NIK-TIGER-BILL, @greysonlalonde, @lorenzejay, @minasami-pr, @theCyberTech, @wishhyt
</Update>
<Update label="1 مايو 2026">
## v1.14.5a1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a1)
## ما الذي تغير
### الميزات
- إضافة معلمة بدء `restore_from_state_id`
- إضافة تسليط الضوء على ExaSearchTool وإعادة تسميته من EXASearchTool
### إصلاحات الأخطاء
- إصلاح المواقع المفقودة لـ crewai في تدفق الإصدار
- ضمان تحميل أحداث المهارات للآثار
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.4
## المساهمون
@akaKuruma, @github-actions[bot], @greysonlalonde, @lorenzejay, @theishangoswami
</Update>
<Update label="1 مايو 2026">
## v1.14.4

View File

@@ -380,6 +380,42 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### تفرع الحالة المستمرة
يدعم `@persist` نمطين متميزين للترطيب في `kickoff` / `kickoff_async`:
- `kickoff(inputs={"id": <uuid>})` — **استئناف**: يحمّل أحدث لقطة لـ UUID المقدم ويستمر في الكتابة تحت نفس `flow_uuid`. يمتد التاريخ.
- `kickoff(restore_from_state_id=<uuid>)` — **تفرع**: يحمّل أحدث لقطة لـ UUID المقدم، يرطّب حالة التشغيل الجديد منها، ثم يعيّن `state.id` جديدًا (مولّدًا تلقائيًا، أو `inputs["id"]` إذا تم تثبيته). تذهب كتابات `@persist` للتشغيل الجديد تحت `state.id` الجديد؛ يتم الحفاظ على تاريخ تدفق المصدر.
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
print(f"[id={self.state.id}] counter={self.state.counter}")
# التشغيل 1: حالة جديدة، العداد 0 -> 1، محفوظ تحت flow_1.state.id
flow_1 = CounterFlow()
flow_1.kickoff()
# التفرع: ترطيب من أحدث لقطة لـ flow_1، لكن باستخدام state.id جديد
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# يبدأ flow_2.state.counter بـ 1 (مرطّب)، ثم تزيده step() إلى 2.
# flow_2.state.id != flow_1.state.id؛ تاريخ flow_1 لم يتغيّر.
```
إذا لم يطابق `restore_from_state_id` المقدم أي حالة مستمرة، يعود kickoff بصمت إلى السلوك الافتراضي — نفس سلوك `inputs["id"]` عند عدم العثور عليه. الجمع بين `restore_from_state_id` و `from_checkpoint` يطلق `ValueError`؛ اختر مصدر ترطيب واحدًا. تثبيت `inputs["id"]` أثناء التفرع يشارك مفتاح الاستمرارية مع تدفق آخر — عادةً ما تريد استخدام `restore_from_state_id` فقط.
### كيف تعمل
1. **تعريف الحالة الفريد**

View File

@@ -146,6 +146,14 @@ class ProductionFlow(Flow[AppState]):
# ...
```
افتراضيًا، يستأنف `@persist` تدفقًا عند توفير `kickoff(inputs={"id": <uuid>})`، مما يمدّ نفس تاريخ `flow_uuid`. لـ **تفرع** تدفق مستمر إلى نسبٍ جديد — ترطيب الحالة من تشغيل سابق ولكن الكتابة تحت `state.id` جديد — مرّر `restore_from_state_id`:
```python
flow.kickoff(restore_from_state_id="<previous-run-state-id>")
```
يحصل التشغيل الجديد على `state.id` جديد (مولّد تلقائيًا، أو `inputs["id"]` إذا تم تثبيته) لذا لا تمتد كتابات `@persist` الخاصة به إلى تاريخ المصدر. الجمع مع `from_checkpoint` يطلق `ValueError`؛ اختر مصدر ترطيب واحدًا.
## الخلاصة
- **ابدأ بتدفق.**

View File

@@ -133,7 +133,7 @@ crew.kickoff()
| **DirectorySearchTool** | أداة RAG للبحث في المجلدات، مفيدة للتنقل في أنظمة الملفات. |
| **DOCXSearchTool** | أداة RAG للبحث في مستندات DOCX، مثالية لمعالجة ملفات Word. |
| **DirectoryReadTool** | تسهّل قراءة ومعالجة هياكل المجلدات ومحتوياتها. |
| **EXASearchTool** | أداة مصممة لإجراء عمليات بحث شاملة عبر مصادر بيانات متنوعة. |
| **ExaSearchTool** | أداة مصممة لإجراء عمليات بحث شاملة عبر مصادر بيانات متنوعة. |
| **FileReadTool** | تُمكّن قراءة واستخراج البيانات من الملفات، مع دعم تنسيقات ملفات متنوعة. |
| **FirecrawlSearchTool** | أداة للبحث في صفحات الويب باستخدام Firecrawl وإرجاع النتائج. |
| **FirecrawlCrawlWebsiteTool** | أداة لزحف صفحات الويب باستخدام Firecrawl. |

View File

@@ -116,6 +116,48 @@ class PersistentCounterFlow(Flow[CounterState]):
return self.state.value
```
#### تفرع الحالة المستمرة
يدعم `@persist` نمطين متميزين للترطيب في `kickoff` / `kickoff_async`. استخدم **استئناف** (`inputs["id"]`) لمواصلة نفس النسب؛ استخدم **تفرع** (`restore_from_state_id`) لبدء نسبٍ جديد من لقطة:
| | `state.id` بعد kickoff | كتابات `@persist` تذهب إلى |
|---|---|---|
| `inputs["id"]` (استئناف) | المعرّف المقدم | المعرّف المقدم (يمد التاريخ) |
| `restore_from_state_id` (تفرع) | معرّف جديد، أو `inputs["id"]` إذا ثُبّت | المعرّف الجديد (المصدر محفوظ) |
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
# التشغيل 1: حالة جديدة، العداد 0 -> 1
flow_1 = CounterFlow()
flow_1.kickoff()
# التفرع: الترطيب من أحدث لقطة لـ flow_1، لكن الكتابة تحت state.id جديد
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# يبدأ flow_2 بـ counter=1 (مرطّب)، ثم تزيده step() إلى 2.
# تاريخ flow_uuid لـ flow_1 لم يتغيّر.
```
ملاحظات السلوك:
- `restore_from_state_id` غير موجود في الاستمرارية → يعود kickoff بصمت إلى السلوك الافتراضي (يعكس سلوك `inputs["id"]` عند عدم العثور عليه). لا يُطلق أي استثناء.
- الجمع بين `restore_from_state_id` و `from_checkpoint` يطلق `ValueError` — يستهدفان نظامي حالة مختلفين (`@persist` مقابل Checkpointing) ولا يمكن الجمع بينهما.
- `restore_from_state_id=None` (افتراضي) متطابق بايت ببايت مع kickoff بدون المعامل.
- تثبيت `inputs["id"]` أثناء التفرع يعني أن التشغيل الجديد يشارك مفتاح الاستمرارية مع تدفق آخر — عادةً ما تريد فقط `restore_from_state_id`.
## أنماط حالة متقدمة
### المنطق الشرطي المبني على الحالة

View File

@@ -1,11 +1,11 @@
---
title: "أداة بحث Exa"
description: "ابحث في الويب باستخدام Exa Search API للعثور على النتائج الأكثر صلة لأي استعلام، مع خيارات لمحتوى الصفحة الكامل والمقتطفات والملخصات."
description: "ابحث في الويب باستخدام Exa Search API للعثور على النتائج الأكثر صلة لأي استعلام، مع خيارات لمحتوى الصفحة الكامل والمقتطفات."
icon: "magnifying-glass"
mode: "wide"
---
تتيح أداة `EXASearchTool` لوكلاء CrewAI البحث في الويب باستخدام [Exa](https://exa.ai/) search API. تُرجع النتائج الأكثر صلة لأي استعلام، مع خيارات لمحتوى الصفحة الكامل والملخصات المولّدة بالذكاء الاصطناعي.
تتيح أداة `ExaSearchTool` لوكلاء CrewAI البحث في الويب باستخدام [Exa](https://exa.ai/) search API. تُرجع النتائج الأكثر صلة لأي استعلام، مع خيارات لمحتوى الصفحة الكامل والمقتطفات الموفرة للرموز.
## التثبيت
@@ -27,15 +27,15 @@ export EXA_API_KEY='your_exa_api_key'
## مثال على الاستخدام
إليك كيفية استخدام `EXASearchTool` مع وكيل CrewAI:
إليك كيفية استخدام `ExaSearchTool` مع وكيل CrewAI:
```python
import os
from crewai import Agent, Task, Crew
from crewai_tools import EXASearchTool
from crewai_tools import ExaSearchTool
# Initialize the tool
exa_tool = EXASearchTool()
exa_tool = ExaSearchTool()
# Create an agent that uses the tool
researcher = Agent(
@@ -66,11 +66,11 @@ print(result)
## خيارات التكوين
تقبل أداة `EXASearchTool` المعاملات التالية أثناء التهيئة:
تقبل أداة `ExaSearchTool` المعاملات التالية أثناء التهيئة:
- `type` (str، اختياري): نوع البحث المستخدم. الافتراضي هو `"auto"`. الخيارات: `"auto"`، `"instant"`، `"fast"`، `"deep"`.
- `highlights` (bool أو dict، اختياري): إرجاع مقتطفات موفرة للرموز أكثر صلة بالاستعلام بدلاً من الصفحة الكاملة. الافتراضي هو `True`. مرر قاموسًا مثل `{"max_characters": 4000}` للتكوين، أو `False` للتعطيل.
- `content` (bool، اختياري): ما إذا كان يجب تضمين محتوى الصفحة الكامل في النتائج. الافتراضي هو `False`.
- `summary` (bool، اختياري): ما إذا كان يجب تضمين ملخصات مولّدة بالذكاء الاصطناعي لكل نتيجة. يتطلب `content=True`. الافتراضي هو `False`.
- `api_key` (str، اختياري): مفتاح Exa API الخاص بك. يعود إلى متغير البيئة `EXA_API_KEY` إذا لم يتم تقديمه.
- `base_url` (str، اختياري): عنوان URL مخصص لخادم API. يعود إلى متغير البيئة `EXA_BASE_URL` إذا لم يتم تقديمه.
@@ -86,25 +86,52 @@ print(result)
يمكنك تكوين الأداة بمعاملات مخصصة للحصول على نتائج أغنى:
```python
# Get full page content with AI summaries
exa_tool = EXASearchTool(
content=True,
summary=True,
# Use 'deep' for thorough, multi-step searches
exa_tool = ExaSearchTool(
highlights=True,
type="deep"
)
# Use it in an agent
agent = Agent(
role="Deep Researcher",
goal="Conduct thorough research with full content and summaries",
goal="Conduct thorough research",
tools=[exa_tool]
)
```
## استخدام Exa عبر MCP
يمكنك أيضًا ربط وكيلك بخادم MCP المستضاف من Exa. مرّر مفتاح API الخاص بك عبر ترويسة `x-api-key`:
```python
from crewai import Agent
from crewai.mcp import MCPServerHTTP
agent = Agent(
role="Research Analyst",
goal="Find and analyze information on the web",
backstory="Expert researcher with access to Exa's tools",
mcps=[
MCPServerHTTP(
url="https://mcp.exa.ai/mcp",
headers={"x-api-key": "YOUR_EXA_API_KEY"},
),
],
)
```
احصل على مفتاح API من [لوحة تحكم Exa](https://dashboard.exa.ai/api-keys). لمزيد من المعلومات حول MCP في CrewAI، راجع [نظرة عامة على MCP](/ar/mcp/overview).
## الميزات
- **مقتطفات موفرة للرموز**: الحصول على المقتطفات الأكثر صلة من كل نتيجة، باستخدام رموز أقل بكثير من النص الكامل
- **البحث الدلالي**: العثور على نتائج بناءً على المعنى، وليس الكلمات المفتاحية فقط
- **استرجاع المحتوى الكامل**: الحصول على النص الكامل لصفحات الويب مع نتائج البحث
- **ملخصات الذكاء الاصطناعي**: الحصول على ملخصات موجزة مولّدة بالذكاء الاصطناعي لكل نتيجة
- **تصفية التاريخ**: تقييد النتائج لفترات زمنية محددة باستخدام فلاتر تاريخ النشر
- **تصفية النطاقات**: تقييد عمليات البحث على نطاقات محددة
- **تصفية النطاقات**: تقييد عمليات البحث على نطاقات محددة
## موارد
- [توثيق Exa](https://exa.ai/docs)
- [لوحة تحكم Exa — إدارة مفاتيح API والاستخدام](https://dashboard.exa.ai)

View File

@@ -4,6 +4,58 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="May 04, 2026">
## v1.14.5a2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a2)
## What's Changed
### Bug Fixes
- Fix task output restoration in finally block
- Include `thoughts_token_count` in completion tokens
- Preserve task outputs across async batch flush
- Forward kwargs to loader calls in `CrewAIRagAdapter`
- Prevent `result_as_answer` from returning hook-block message as final answer
- Prevent `result_as_answer` from returning error as final answer
- Use `acall` for output conversion in async paths
- Prevent shared LLM stop words mutation across agents
- Handle `BaseModel` input in `convert_to_model`
### Documentation
- Document additional environment variables
- Update changelog and version for v1.14.5a1
## Contributors
@NIK-TIGER-BILL, @greysonlalonde, @lorenzejay, @minasami-pr, @theCyberTech, @wishhyt
</Update>
<Update label="May 01, 2026">
## v1.14.5a1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a1)
## What's Changed
### Features
- Add `restore_from_state_id` kickoff parameter
- Add highlights to ExaSearchTool and rename from EXASearchTool
### Bug Fixes
- Fix missing crewai pin sites in release flow
- Ensure skills loading events for traces
### Documentation
- Update changelog and version for v1.14.4
## Contributors
@akaKuruma, @github-actions[bot], @greysonlalonde, @lorenzejay, @theishangoswami
</Update>
<Update label="May 01, 2026">
## v1.14.4

View File

@@ -380,6 +380,42 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### Forking Persisted State
`@persist` supports two distinct hydration modes on `kickoff` / `kickoff_async`:
- `kickoff(inputs={"id": <uuid>})` — **resume**: load the latest snapshot for the supplied UUID and continue writing under the same `flow_uuid`. The history extends.
- `kickoff(restore_from_state_id=<uuid>)` — **fork**: load the latest snapshot for the supplied UUID, hydrate the new run's state from it, and assign a fresh `state.id` (auto-generated, or `inputs["id"]` if pinned). The new run's `@persist` writes land under the new `state.id`; the source flow's history is preserved.
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
print(f"[id={self.state.id}] counter={self.state.counter}")
# Run 1: fresh state, counter 0 -> 1, persisted under flow_1.state.id
flow_1 = CounterFlow()
flow_1.kickoff()
# Fork: hydrate from flow_1's latest snapshot, but use a NEW state.id
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2.state.counter starts at 1 (hydrated), then step() bumps it to 2.
# flow_2.state.id != flow_1.state.id; flow_1's history is unchanged.
```
If the supplied `restore_from_state_id` does not match any persisted state, the kickoff falls back silently — same as the existing `inputs["id"]` resume not-found behavior. Combining `restore_from_state_id` with `from_checkpoint` raises a `ValueError`; pick one hydration source. Pinning `inputs["id"]` while forking shares a persistence key with another flow — usually you want only `restore_from_state_id`.
### How It Works
1. **Unique State Identification**

View File

@@ -146,6 +146,14 @@ class ProductionFlow(Flow[AppState]):
# ...
```
By default, `@persist` resumes a flow when `kickoff(inputs={"id": <uuid>})` is supplied, extending the same `flow_uuid` history. To **fork** a persisted flow into a new lineage — hydrate state from a previous run but write under a fresh `state.id` — pass `restore_from_state_id`:
```python
flow.kickoff(restore_from_state_id="<previous-run-state-id>")
```
The new run gets a fresh `state.id` (auto-generated, or `inputs["id"]` if pinned) so its `@persist` writes don't extend the source's history. Combining with `from_checkpoint` raises a `ValueError`; pick one hydration source.
## Summary
- **Start with a Flow.**

View File

@@ -133,7 +133,7 @@ Here is a list of the available tools and their descriptions:
| **DirectorySearchTool** | A RAG tool for searching within directories, useful for navigating through file systems. |
| **DOCXSearchTool** | A RAG tool aimed at searching within DOCX documents, ideal for processing Word files. |
| **DirectoryReadTool** | Facilitates reading and processing of directory structures and their contents. |
| **EXASearchTool** | A tool designed for performing exhaustive searches across various data sources. |
| **ExaSearchTool** | Search the web with Exa, the fastest and most accurate web search API. Supports token-efficient highlights and full page content. |
| **FileReadTool** | Enables reading and extracting data from files, supporting various file formats. |
| **FirecrawlSearchTool** | A tool to search webpages using Firecrawl and return the results. |
| **FirecrawlCrawlWebsiteTool** | A tool for crawling webpages using Firecrawl. |

View File

@@ -346,6 +346,48 @@ class SelectivePersistFlow(Flow):
return f"Complete with count {self.state['count']}"
```
#### Forking Persisted State
`@persist` supports two distinct hydration modes on `kickoff` / `kickoff_async`. Use **resume** (`inputs["id"]`) to continue the same lineage; use **fork** (`restore_from_state_id`) to start a new lineage seeded from a snapshot:
| | `state.id` after kickoff | `@persist` writes land under |
|---|---|---|
| `inputs["id"]` (resume) | supplied id | supplied id (extends history) |
| `restore_from_state_id` (fork) | fresh id, or `inputs["id"]` if pinned | new id (source preserved) |
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
# Run 1: fresh state, counter 0 -> 1
flow_1 = CounterFlow()
flow_1.kickoff()
# Fork: hydrate from flow_1's latest snapshot, but write under a NEW state.id
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2 starts with counter=1 (hydrated), then step() bumps it to 2.
# flow_1's flow_uuid history is unchanged.
```
Behavior notes:
- `restore_from_state_id` not found in persistence → the kickoff falls back silently to default behavior (mirrors the existing `inputs["id"]` resume not-found behavior). No exception is raised.
- Combining `restore_from_state_id` with `from_checkpoint` raises a `ValueError` — they target different state systems (`@persist` vs. Checkpointing) and cannot be combined.
- `restore_from_state_id=None` (default) is byte-identical to a kickoff without the parameter.
- Pinning `inputs["id"]` while forking means the new run shares a persistence key with another flow — usually you want only `restore_from_state_id`.
## Advanced State Patterns

View File

@@ -1,11 +1,11 @@
---
title: "Exa Search Tool"
description: "Search the web using the Exa Search API to find the most relevant results for any query, with options for full page content, highlights, and summaries."
description: "Search the web with Exa, the fastest and most accurate web search API. Get token-efficient highlights and full page content."
icon: "magnifying-glass"
mode: "wide"
---
The `EXASearchTool` lets CrewAI agents search the web using the [Exa](https://exa.ai/) search API. It returns the most relevant results for any query, with options for full page content and AI-generated summaries.
The `ExaSearchTool` lets CrewAI agents search the web using [Exa](https://exa.ai/), the fastest and most accurate web search API. It returns the most relevant results for any query, with options for token-efficient highlights and full page content.
## Installation
@@ -27,15 +27,15 @@ Get an API key from the [Exa dashboard](https://dashboard.exa.ai/api-keys).
## Example Usage
Here's how to use the `EXASearchTool` within a CrewAI agent:
Here's how to use the `ExaSearchTool` within a CrewAI agent:
```python
import os
from crewai import Agent, Task, Crew
from crewai_tools import EXASearchTool
from crewai_tools import ExaSearchTool
# Initialize the tool
exa_tool = EXASearchTool()
exa_tool = ExaSearchTool()
# Create an agent that uses the tool
researcher = Agent(
@@ -66,11 +66,11 @@ print(result)
## Configuration Options
The `EXASearchTool` accepts the following parameters during initialization:
The `ExaSearchTool` accepts the following parameters during initialization:
- `type` (str, optional): The search type to use. Defaults to `"auto"`. Options: `"auto"`, `"instant"`, `"fast"`, `"deep"`.
- `highlights` (bool or dict, optional): Return token-efficient excerpts most relevant to the query instead of the full page. Defaults to `True`. Pass a dict like `{"max_characters": 4000}` to configure, or `False` to disable.
- `content` (bool, optional): Whether to include full page content in results. Defaults to `False`.
- `summary` (bool, optional): Whether to include AI-generated summaries of each result. Requires `content=True`. Defaults to `False`.
- `api_key` (str, optional): Your Exa API key. Falls back to the `EXA_API_KEY` environment variable if not provided.
- `base_url` (str, optional): Custom API server URL. Falls back to the `EXA_BASE_URL` environment variable if not provided.
@@ -83,28 +83,70 @@ When calling the tool (or when an agent invokes it), the following search parame
## Advanced Usage
You can configure the tool with custom parameters for richer results:
For most agent workflows we recommend `highlights` — it returns the most relevant excerpts from each result and uses far fewer tokens than full page content:
```python
# Get full page content with AI summaries
exa_tool = EXASearchTool(
content=True,
summary=True,
type="deep"
# Get token-efficient excerpts most relevant to the query
exa_tool = ExaSearchTool(
highlights=True,
type="auto",
)
# Use it in an agent
agent = Agent(
role="Deep Researcher",
goal="Conduct thorough research with full content and summaries",
role="Researcher",
goal="Answer questions with current web data",
tools=[exa_tool]
)
```
For thorough, multi-step searches, use `type="deep"`:
```python
exa_tool = ExaSearchTool(
highlights=True,
type="deep",
)
```
For more on choosing between highlights and full content, see the [Exa search best practices](https://exa.ai/docs/reference/search-best-practices).
## Using Exa via MCP
You can also connect your agent to Exa's hosted MCP server. Pass your API key with the `x-api-key` header:
```python
from crewai import Agent
from crewai.mcp import MCPServerHTTP
agent = Agent(
role="Research Analyst",
goal="Find and analyze information on the web",
backstory="Expert researcher with access to Exa's tools",
mcps=[
MCPServerHTTP(
url="https://mcp.exa.ai/mcp",
headers={"x-api-key": "YOUR_EXA_API_KEY"},
),
],
)
```
Get your API key from the [Exa dashboard](https://dashboard.exa.ai/api-keys). For more on MCP in CrewAI, see the [MCP overview](/en/mcp/overview).
## Features
- **Token-Efficient Highlights**: Get the most relevant excerpts from each result, ~10x fewer tokens than full text
- **Semantic Search**: Find results based on meaning, not just keywords
- **Full Content Retrieval**: Get the full text of web pages alongside search results
- **AI Summaries**: Get concise, AI-generated summaries of each result
- **Date Filtering**: Limit results to specific time periods with published date filters
- **Domain Filtering**: Restrict searches to specific domains
<Note>
`EXASearchTool` is a deprecated alias for `ExaSearchTool`. Existing imports continue to work but will emit a deprecation warning; please migrate to `ExaSearchTool`.
</Note>
## Resources
- [Exa documentation](https://exa.ai/docs)
- [Exa dashboard — manage API keys and usage](https://dashboard.exa.ai)

View File

@@ -4,6 +4,58 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 5월 4일">
## v1.14.5a2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a2)
## 변경 사항
### 버그 수정
- finally 블록에서 작업 출력 복원 수정
- 완료 토큰에 `thoughts_token_count` 포함
- 비동기 배치 플러시 간 작업 출력 보존
- `CrewAIRagAdapter`의 로더 호출에 kwargs 전달
- `result_as_answer`가 후크 차단 메시지를 최종 답변으로 반환하지 않도록 방지
- `result_as_answer`가 오류를 최종 답변으로 반환하지 않도록 방지
- 비동기 경로에서 출력 변환을 위해 `acall` 사용
- 에이전트 간 공유 LLM 중지 단어 변형 방지
- `convert_to_model`에서 `BaseModel` 입력 처리
### 문서화
- 추가 환경 변수 문서화
- v1.14.5a1에 대한 변경 로그 및 버전 업데이트
## 기여자
@NIK-TIGER-BILL, @greysonlalonde, @lorenzejay, @minasami-pr, @theCyberTech, @wishhyt
</Update>
<Update label="2026년 5월 1일">
## v1.14.5a1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a1)
## 변경 사항
### 기능
- `restore_from_state_id` 시작 매개변수 추가
- ExaSearchTool에 하이라이트 추가 및 EXASearchTool에서 이름 변경
### 버그 수정
- 릴리스 흐름에서 crewai 핀 사이트 누락 수정
- 트레이스를 위한 기술 로딩 이벤트 보장
### 문서
- v1.14.4에 대한 변경 로그 및 버전 업데이트
## 기여자
@akaKuruma, @github-actions[bot], @greysonlalonde, @lorenzejay, @theishangoswami
</Update>
<Update label="2026년 5월 1일">
## v1.14.4

View File

@@ -373,6 +373,42 @@ class AnotherFlow(Flow[dict]):
print("Method-level persisted runs:", self.state["runs"])
```
### 영속 상태 포크하기
`@persist`는 `kickoff` / `kickoff_async`에서 두 가지 별개의 하이드레이션 모드를 지원합니다:
- `kickoff(inputs={"id": <uuid>})` — **재개(resume)**: 제공된 UUID에 대한 최신 스냅샷을 로드하고 동일한 `flow_uuid` 아래에서 계속 기록합니다. 기록이 확장됩니다.
- `kickoff(restore_from_state_id=<uuid>)` — **포크(fork)**: 제공된 UUID에 대한 최신 스냅샷을 로드하고 새 실행의 상태를 하이드레이트한 후, 새로운 `state.id`(자동 생성, 또는 `inputs["id"]`가 고정된 경우 그 값)를 할당합니다. 새 실행의 `@persist` 기록은 새로운 `state.id` 아래에 저장되며, 원본 플로우의 기록은 보존됩니다.
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
print(f"[id={self.state.id}] counter={self.state.counter}")
# 실행 1: 새 상태, counter 0 -> 1, flow_1.state.id 아래에 저장됨
flow_1 = CounterFlow()
flow_1.kickoff()
# 포크: flow_1의 최신 스냅샷에서 하이드레이트하지만, 새 state.id를 사용
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2.state.counter는 1(하이드레이트)로 시작하고, step()이 2로 증가시킵니다.
# flow_2.state.id != flow_1.state.id; flow_1의 기록은 변경되지 않습니다.
```
제공된 `restore_from_state_id`가 어떤 영속 상태와도 일치하지 않으면, kickoff는 조용히 기본 동작으로 폴백됩니다 — 기존 `inputs["id"]`의 미발견 동작과 동일합니다. `restore_from_state_id`를 `from_checkpoint`와 결합하면 `ValueError`가 발생합니다; 하나의 하이드레이션 소스를 선택하세요. 포크 중 `inputs["id"]`를 고정하면 다른 플로우와 영속 키를 공유하게 됩니다 — 일반적으로 `restore_from_state_id`만 사용하는 것이 좋습니다.
### 작동 방식
1. **고유 상태 식별**

View File

@@ -146,6 +146,14 @@ class ProductionFlow(Flow[AppState]):
# ...
```
기본적으로, `@persist`는 `kickoff(inputs={"id": <uuid>})`가 제공될 때 플로우를 재개하여 동일한 `flow_uuid` 기록을 확장합니다. 영속된 플로우를 새 계보로 **포크**하려면 — 이전 실행에서 상태를 하이드레이트하지만 새로운 `state.id` 아래에 기록 — `restore_from_state_id`를 전달하세요:
```python
flow.kickoff(restore_from_state_id="<previous-run-state-id>")
```
새 실행은 새로운 `state.id`(자동 생성, 또는 `inputs["id"]`가 고정된 경우 그 값)를 받아 `@persist` 기록이 원본의 기록을 확장하지 않도록 합니다. `from_checkpoint`와 결합하면 `ValueError`가 발생합니다; 하나의 하이드레이션 소스를 선택하세요.
## 요약
- **Flow로 시작하세요.**

View File

@@ -132,7 +132,7 @@ crew.kickoff()
| **DirectorySearchTool** | 디렉터리 내에서 검색하는 RAG 도구로, 파일 시스템을 탐색할 때 유용합니다. |
| **DOCXSearchTool** | DOCX 문서 내에서 검색하는 데 특화된 RAG 도구로, Word 파일을 처리할 때 이상적입니다. |
| **DirectoryReadTool** | 디렉터리 구조와 그 내용을 읽고 처리하도록 지원하는 도구입니다. |
| **EXASearchTool** | 다양한 데이터 소스를 폭넓게 검색하기 위해 설계된 도구입니다. |
| **ExaSearchTool** | 다양한 데이터 소스를 폭넓게 검색하기 위해 설계된 도구입니다. |
| **FileReadTool** | 다양한 파일 형식을 지원하며 파일에서 데이터를 읽고 추출할 수 있는 도구입니다. |
| **FirecrawlSearchTool** | Firecrawl을 이용해 웹페이지를 검색하고 결과를 반환하는 도구입니다. |
| **FirecrawlCrawlWebsiteTool** | Firecrawl을 사용해 웹페이지를 크롤링하는 도구입니다. |

View File

@@ -346,6 +346,48 @@ class SelectivePersistFlow(Flow):
return f"Complete with count {self.state['count']}"
```
#### 영속 상태 포크하기
`@persist`는 `kickoff` / `kickoff_async`에서 두 가지 별개의 하이드레이션 모드를 지원합니다. 동일한 계보를 계속하려면 **재개**(`inputs["id"]`)를 사용하고, 스냅샷에서 시작하는 새 계보를 시작하려면 **포크**(`restore_from_state_id`)를 사용하세요:
| | kickoff 후 `state.id` | `@persist` 기록 위치 |
|---|---|---|
| `inputs["id"]` (재개) | 제공된 id | 제공된 id (기록 확장) |
| `restore_from_state_id` (포크) | 새 id, 또는 고정 시 `inputs["id"]` | 새 id (원본 보존) |
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
# 실행 1: 새 상태, counter 0 -> 1
flow_1 = CounterFlow()
flow_1.kickoff()
# 포크: flow_1의 최신 스냅샷에서 하이드레이트, 단 새 state.id에 기록
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2는 counter=1(하이드레이트)로 시작하고, step()이 2로 증가시킵니다.
# flow_1의 flow_uuid 기록은 변경되지 않습니다.
```
동작 노트:
- `restore_from_state_id`가 영속에서 발견되지 않음 → kickoff는 조용히 기본 동작으로 폴백됩니다 (기존 `inputs["id"]`의 미발견 동작 미러링). 예외는 발생하지 않습니다.
- `restore_from_state_id`를 `from_checkpoint`와 결합하면 `ValueError`가 발생합니다 — 서로 다른 상태 시스템(`@persist` 대 Checkpointing)을 대상으로 하므로 결합할 수 없습니다.
- `restore_from_state_id=None`(기본값)은 매개변수 없는 kickoff와 바이트 단위로 동일합니다.
- 포크 중 `inputs["id"]`를 고정하면 새 실행이 다른 플로우와 영속 키를 공유함을 의미합니다 — 일반적으로 `restore_from_state_id`만 사용하는 것이 좋습니다.
## 고급 상태 패턴
### 상태 기반 조건부 로직

View File

@@ -1,15 +1,15 @@
---
title: EXA 검색 웹 로더
description: EXASearchTool은 인터넷 전반에 걸쳐 텍스트의 내용에서 지정된 쿼리에 대한 시맨틱 검색을 수행하도록 설계되었습니다.
description: ExaSearchTool은 인터넷 전반에 걸쳐 텍스트의 내용에서 지정된 쿼리에 대한 시맨틱 검색을 수행하도록 설계되었습니다.
icon: globe-pointer
mode: "wide"
---
# `EXASearchTool`
# `ExaSearchTool`
## 설명
EXASearchTool은 텍스트의 내용을 기반으로 지정된 쿼리를 인터넷 전반에 걸쳐 의미론적으로 검색하도록 설계되었습니다.
ExaSearchTool은 텍스트의 내용을 기반으로 지정된 쿼리를 인터넷 전반에 걸쳐 의미론적으로 검색하도록 설계되었습니다.
사용자가 제공한 쿼리를 기반으로 가장 관련성 높은 검색 결과를 가져오고 표시하기 위해 [exa.ai](https://exa.ai/) API를 활용합니다.
## 설치
@@ -25,15 +25,15 @@ pip install 'crewai[tools]'
다음 예제는 도구를 초기화하고 주어진 쿼리로 검색을 실행하는 방법을 보여줍니다:
```python Code
from crewai_tools import EXASearchTool
from crewai_tools import ExaSearchTool
# Initialize the tool for internet searching capabilities
tool = EXASearchTool()
tool = ExaSearchTool()
```
## 시작 단계
EXASearchTool을 효과적으로 사용하려면 다음 단계를 따르세요:
ExaSearchTool을 효과적으로 사용하려면 다음 단계를 따르세요:
<Steps>
<Step title="패키지 설치">
@@ -47,7 +47,35 @@ EXASearchTool을 효과적으로 사용하려면 다음 단계를 따르세요:
</Step>
</Steps>
## MCP를 통한 Exa 사용
Exa가 호스팅하는 MCP 서버에 에이전트를 연결할 수도 있습니다. API 키는 `x-api-key` 헤더로 전달하세요:
```python
from crewai import Agent
from crewai.mcp import MCPServerHTTP
agent = Agent(
role="Research Analyst",
goal="Find and analyze information on the web",
backstory="Expert researcher with access to Exa's tools",
mcps=[
MCPServerHTTP(
url="https://mcp.exa.ai/mcp",
headers={"x-api-key": "YOUR_EXA_API_KEY"},
),
],
)
```
API 키는 [Exa 대시보드](https://dashboard.exa.ai/api-keys)에서 발급받을 수 있습니다. CrewAI에서의 MCP 사용에 대한 자세한 내용은 [MCP 개요](/ko/mcp/overview)를 참고하세요.
## 결론
`EXASearchTool`을 Python 프로젝트에 통합함으로써, 사용자는 애플리케이션 내에서 실시간으로 인터넷을 직접 검색할 수 있는 능력을 얻게 됩니다.
`ExaSearchTool`을 Python 프로젝트에 통합함으로써, 사용자는 애플리케이션 내에서 실시간으로 인터넷을 직접 검색할 수 있는 능력을 얻게 됩니다.
제공된 설정 및 사용 지침을 따르면, 이 도구를 프로젝트에 포함하는 과정이 간편하고 직관적입니다.
## 참고 자료
- [Exa 공식 문서](https://exa.ai/docs)
- [Exa 대시보드 — API 키 및 사용량 관리](https://dashboard.exa.ai)

View File

@@ -4,6 +4,58 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="04 mai 2026">
## v1.14.5a2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a2)
## O que Mudou
### Correções de Bugs
- Corrigir a restauração da saída da tarefa no bloco finally
- Incluir `thoughts_token_count` nos tokens de conclusão
- Preservar as saídas das tarefas durante o descarregamento assíncrono em lote
- Encaminhar kwargs para chamadas de carregador em `CrewAIRagAdapter`
- Impedir que `result_as_answer` retorne mensagem de bloqueio de hook como resposta final
- Impedir que `result_as_answer` retorne erro como resposta final
- Usar `acall` para conversão de saída em caminhos assíncronos
- Prevenir a mutação de palavras de parada compartilhadas do LLM entre agentes
- Lidar com entrada `BaseModel` em `convert_to_model`
### Documentação
- Documentar variáveis de ambiente adicionais
- Atualizar changelog e versão para v1.14.5a1
## Contribuidores
@NIK-TIGER-BILL, @greysonlalonde, @lorenzejay, @minasami-pr, @theCyberTech, @wishhyt
</Update>
<Update label="01 mai 2026">
## v1.14.5a1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a1)
## O que Mudou
### Recursos
- Adicionar parâmetro de início `restore_from_state_id`
- Adicionar destaques ao ExaSearchTool e renomear de EXASearchTool
### Correções de Bugs
- Corrigir sites de pinos do crewai ausentes no fluxo de lançamento
- Garantir eventos de carregamento de habilidades para rastros
### Documentação
- Atualizar changelog e versão para v1.14.4
## Contribuidores
@akaKuruma, @github-actions[bot], @greysonlalonde, @lorenzejay, @theishangoswami
</Update>
<Update label="01 mai 2026">
## v1.14.4

View File

@@ -193,6 +193,42 @@ Para um controle mais granular, você pode aplicar @persist em métodos específ
# (O código não é traduzido)
```
### Forking de Estado Persistido
`@persist` suporta dois modos distintos de hidratação em `kickoff` / `kickoff_async`:
- `kickoff(inputs={"id": <uuid>})` — **resume**: carrega o snapshot mais recente do UUID informado e continua escrevendo sob o mesmo `flow_uuid`. O histórico se estende.
- `kickoff(restore_from_state_id=<uuid>)` — **fork**: carrega o snapshot mais recente do UUID informado, hidrata o estado da nova execução a partir dele, e atribui um novo `state.id` (auto-gerado, ou `inputs["id"]` se fixado). As escritas do `@persist` da nova execução vão para o novo `state.id`; o histórico do flow de origem é preservado.
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
print(f"[id={self.state.id}] counter={self.state.counter}")
# Execução 1: estado novo, counter 0 -> 1, persistido sob flow_1.state.id
flow_1 = CounterFlow()
flow_1.kickoff()
# Fork: hidrata do snapshot mais recente de flow_1, mas usa um state.id NOVO
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2.state.counter começa em 1 (hidratado), e step() incrementa para 2.
# flow_2.state.id != flow_1.state.id; o histórico de flow_1 não é alterado.
```
Se o `restore_from_state_id` informado não corresponder a nenhum estado persistido, o kickoff retorna silenciosamente ao comportamento padrão — o mesmo comportamento do `inputs["id"]` quando não encontrado. Combinar `restore_from_state_id` com `from_checkpoint` lança um `ValueError`; escolha uma única fonte de hidratação. Fixar `inputs["id"]` durante o fork compartilha uma chave de persistência com outro flow — geralmente você quer apenas `restore_from_state_id`.
### Como Funciona
1. **Identificação Única do Estado**

View File

@@ -146,6 +146,14 @@ class ProductionFlow(Flow[AppState]):
# ...
```
Por padrão, `@persist` retoma um flow quando `kickoff(inputs={"id": <uuid>})` é informado, estendendo o mesmo histórico do `flow_uuid`. Para **forkar** um flow persistido em uma nova linhagem — hidratar o estado a partir de uma execução anterior mas escrever sob um novo `state.id` — passe `restore_from_state_id`:
```python
flow.kickoff(restore_from_state_id="<previous-run-state-id>")
```
A nova execução recebe um novo `state.id` (auto-gerado, ou `inputs["id"]` se fixado), então suas escritas do `@persist` não estendem o histórico da origem. Combinar com `from_checkpoint` lança um `ValueError`; escolha uma única fonte de hidratação.
## Resumo
- **Comece com um Flow.**

View File

@@ -133,7 +133,7 @@ Aqui está uma lista das ferramentas disponíveis e suas descrições:
| **DirectorySearchTool** | Ferramenta RAG para busca em diretórios, útil para navegação em sistemas de arquivos. |
| **DOCXSearchTool** | Ferramenta RAG voltada para busca em documentos DOCX, ideal para processar arquivos Word. |
| **DirectoryReadTool** | Facilita a leitura e processamento de estruturas de diretórios e seus conteúdos. |
| **EXASearchTool** | Ferramenta projetada para buscas exaustivas em diversas fontes de dados. |
| **ExaSearchTool** | Ferramenta projetada para buscas exaustivas em diversas fontes de dados. |
| **FileReadTool** | Permite a leitura e extração de dados de arquivos, suportando diversos formatos. |
| **FirecrawlSearchTool** | Ferramenta para buscar páginas web usando Firecrawl e retornar os resultados. |
| **FirecrawlCrawlWebsiteTool** | Ferramenta para rastrear páginas web utilizando o Firecrawl. |

View File

@@ -167,6 +167,48 @@ Para mais controle, você pode aplicar `@persist()` em métodos específicos:
# código não traduzido
```
#### Forking de Estado Persistido
`@persist` suporta dois modos distintos de hidratação em `kickoff` / `kickoff_async`. Use **resume** (`inputs["id"]`) para continuar a mesma linhagem; use **fork** (`restore_from_state_id`) para iniciar uma nova linhagem a partir de um snapshot:
| | `state.id` após o kickoff | Escritas do `@persist` vão para |
|---|---|---|
| `inputs["id"]` (resume) | id informado | id informado (estende o histórico) |
| `restore_from_state_id` (fork) | id novo, ou `inputs["id"]` se fixado | id novo (origem preservada) |
```python
from crewai.flow.flow import Flow, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
id: str = ""
counter: int = 0
@persist
class CounterFlow(Flow[CounterState]):
@start()
def step(self):
self.state.counter += 1
# Execução 1: estado novo, counter 0 -> 1
flow_1 = CounterFlow()
flow_1.kickoff()
# Fork: hidrata do snapshot mais recente de flow_1, mas escreve sob um state.id NOVO
flow_2 = CounterFlow()
flow_2.kickoff(restore_from_state_id=flow_1.state.id)
# flow_2 começa com counter=1 (hidratado), e step() incrementa para 2.
# O histórico do flow_uuid de flow_1 não é alterado.
```
Notas sobre o comportamento:
- `restore_from_state_id` não encontrado na persistência → o kickoff retorna silenciosamente ao comportamento padrão (espelha o comportamento de `inputs["id"]` quando não encontrado). Nenhuma exceção é lançada.
- Combinar `restore_from_state_id` com `from_checkpoint` lança um `ValueError` — eles miram sistemas de estado diferentes (`@persist` vs. Checkpointing) e não podem ser combinados.
- `restore_from_state_id=None` (padrão) é byte-idêntico a um kickoff sem o parâmetro.
- Fixar `inputs["id"]` durante o fork significa que a nova execução compartilha uma chave de persistência com outro flow — geralmente você quer apenas `restore_from_state_id`.
## Padrões Avançados de Estado
### Lógica Condicional Baseada no Estado

View File

@@ -1,15 +1,15 @@
---
title: Carregador Web EXA Search
description: O `EXASearchTool` foi projetado para realizar uma busca semântica para uma consulta especificada a partir do conteúdo de um texto em toda a internet.
description: O `ExaSearchTool` foi projetado para realizar uma busca semântica para uma consulta especificada a partir do conteúdo de um texto em toda a internet.
icon: globe-pointer
mode: "wide"
---
# `EXASearchTool`
# `ExaSearchTool`
## Descrição
O EXASearchTool foi projetado para realizar uma busca semântica para uma consulta especificada a partir do conteúdo de um texto em toda a internet.
O ExaSearchTool foi projetado para realizar uma busca semântica para uma consulta especificada a partir do conteúdo de um texto em toda a internet.
Ele utiliza a API da [exa.ai](https://exa.ai/) para buscar e exibir os resultados de pesquisa mais relevantes com base na consulta fornecida pelo usuário.
## Instalação
@@ -25,15 +25,15 @@ pip install 'crewai[tools]'
O exemplo a seguir demonstra como inicializar a ferramenta e executar uma busca com uma consulta determinada:
```python Code
from crewai_tools import EXASearchTool
from crewai_tools import ExaSearchTool
# Initialize the tool for internet searching capabilities
tool = EXASearchTool()
tool = ExaSearchTool()
```
## Etapas para Começar
Para usar o EXASearchTool de forma eficaz, siga estas etapas:
Para usar o ExaSearchTool de forma eficaz, siga estas etapas:
<Steps>
<Step title="Instalação do Pacote">
@@ -47,7 +47,35 @@ Para usar o EXASearchTool de forma eficaz, siga estas etapas:
</Step>
</Steps>
## Usando o Exa via MCP
Você também pode conectar seu agente ao servidor MCP hospedado pelo Exa. Passe sua chave de API no cabeçalho `x-api-key`:
```python
from crewai import Agent
from crewai.mcp import MCPServerHTTP
agent = Agent(
role="Research Analyst",
goal="Find and analyze information on the web",
backstory="Expert researcher with access to Exa's tools",
mcps=[
MCPServerHTTP(
url="https://mcp.exa.ai/mcp",
headers={"x-api-key": "YOUR_EXA_API_KEY"},
),
],
)
```
Obtenha sua chave de API no [painel da Exa](https://dashboard.exa.ai/api-keys). Para mais informações sobre MCP no CrewAI, consulte a [visão geral do MCP](/pt-BR/mcp/overview).
## Conclusão
Ao integrar o `EXASearchTool` em projetos Python, os usuários ganham a capacidade de realizar buscas relevantes e em tempo real pela internet diretamente de suas aplicações.
Seguindo as orientações de configuração e uso fornecidas, a incorporação desta ferramenta em projetos torna-se simples e direta.
Ao integrar o `ExaSearchTool` em projetos Python, os usuários ganham a capacidade de realizar buscas relevantes e em tempo real pela internet diretamente de suas aplicações.
Seguindo as orientações de configuração e uso fornecidas, a incorporação desta ferramenta em projetos torna-se simples e direta.
## Recursos
- [Documentação do Exa](https://exa.ai/docs)
- [Painel do Exa — gerenciar chaves de API e uso](https://dashboard.exa.ai)

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.4"
__version__ = "1.14.5a2"

View File

@@ -26,7 +26,7 @@ CrewAI provides an extensive collection of powerful tools ready to enhance your
- **Web Scraping**: `ScrapeWebsiteTool`, `SeleniumScrapingTool`
- **Database Integrations**: `MySQLSearchTool`
- **Vector Database Integrations**: `MongoDBVectorSearchTool`, `QdrantVectorSearchTool`, `WeaviateVectorSearchTool`
- **API Integrations**: `SerperApiTool`, `EXASearchTool`
- **API Integrations**: `SerperApiTool`, `ExaSearchTool`
- **AI-powered Tools**: `DallETool`, `VisionTool`, `StagehandTool`
And many more robust tools to simplify your agent integrations.

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.4",
"crewai==1.14.5a2",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -107,7 +107,7 @@ stagehand = [
"stagehand>=0.4.1",
]
github = [
"gitpython>=3.1.41,<4",
"gitpython>=3.1.47,<4",
"PyGithub==1.59.1",
]
rag = [

View File

@@ -76,7 +76,7 @@ from crewai_tools.tools.e2b_sandbox_tool import (
E2BFileTool,
E2BPythonTool,
)
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool, ExaSearchTool
from crewai_tools.tools.file_read_tool.file_read_tool import FileReadTool
from crewai_tools.tools.file_writer_tool.file_writer_tool import FileWriterTool
from crewai_tools.tools.files_compressor_tool.files_compressor_tool import (
@@ -258,6 +258,7 @@ __all__ = [
"E2BPythonTool",
"EXASearchTool",
"EnterpriseActionTool",
"ExaSearchTool",
"FileCompressorTool",
"FileReadTool",
"FileWriterTool",
@@ -329,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.4"
__version__ = "1.14.5a2"

View File

@@ -268,7 +268,9 @@ class CrewAIRagAdapter(Adapter):
file_chunker = file_data_type.get_chunker()
file_source = SourceContent(file_path)
file_result: LoaderResult = file_loader.load(file_source)
file_result: LoaderResult = file_loader.load(
file_source, **kwargs
)
file_chunks = file_chunker.chunk(file_result.content)
@@ -319,7 +321,7 @@ class CrewAIRagAdapter(Adapter):
loader = data_type.get_loader()
chunker = data_type.get_chunker()
loader_result: LoaderResult = loader.load(source_content)
loader_result: LoaderResult = loader.load(source_content, **kwargs)
chunks = chunker.chunk(loader_result.content)

View File

@@ -65,7 +65,7 @@ from crewai_tools.tools.e2b_sandbox_tool import (
E2BFileTool,
E2BPythonTool,
)
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool, ExaSearchTool
from crewai_tools.tools.file_read_tool.file_read_tool import FileReadTool
from crewai_tools.tools.file_writer_tool.file_writer_tool import FileWriterTool
from crewai_tools.tools.files_compressor_tool.files_compressor_tool import (
@@ -242,6 +242,7 @@ __all__ = [
"E2BFileTool",
"E2BPythonTool",
"EXASearchTool",
"ExaSearchTool",
"FileCompressorTool",
"FileReadTool",
"FileWriterTool",

View File

@@ -1,7 +1,7 @@
# EXASearchTool Documentation
# ExaSearchTool Documentation
## Description
This tool is designed to perform a semantic search for a specified query from a text's content across the internet. It utilizes the `https://exa.ai/` API to fetch and display the most relevant search results based on the query provided by the user.
This tool lets CrewAI agents search the web using [Exa](https://exa.ai/), the fastest and most accurate web search API. By default the tool returns token-efficient highlights of the most relevant results for any query; you can also opt in to full page content.
## Installation
To incorporate this tool into your project, follow the installation instructions below:
@@ -10,21 +10,23 @@ uv add crewai[tools] exa_py
```
## Example
The following example demonstrates how to initialize the tool and execute a search with a given query:
The following example demonstrates how to initialize the tool and run a search:
```python
from crewai_tools import EXASearchTool
from crewai_tools import ExaSearchTool
# Initialize the tool for internet searching capabilities
tool = EXASearchTool(api_key="your_api_key")
# Default: results with token-efficient highlights
tool = ExaSearchTool(api_key="your_api_key", highlights=True)
```
## Steps to Get Started
To effectively use the `EXASearchTool`, follow these steps:
To effectively use the `ExaSearchTool`, follow these steps:
1. **Package Installation**: Confirm that the `crewai[tools]` package is installed in your Python environment.
2. **API Key Acquisition**: Acquire a `https://exa.ai/` API key by registering for a free account at `https://exa.ai/`.
3. **Environment Configuration**: Store your obtained API key in an environment variable named `EXA_API_KEY` to facilitate its use by the tool.
2. **API Key Acquisition**: Get an Exa API key from the [Exa dashboard](https://dashboard.exa.ai/api-keys).
3. **Environment Configuration**: Store your API key in an environment variable named `EXA_API_KEY` so the tool can pick it up automatically.
## Conclusion
By integrating the `EXASearchTool` into Python projects, users gain the ability to conduct real-time, relevant searches across the internet directly from their applications. By adhering to the setup and usage guidelines provided, incorporating this tool into projects is streamlined and straightforward.
For details on choosing between highlights and full content, see the [Exa search best practices](https://exa.ai/docs/reference/search-best-practices).
## Note
`EXASearchTool` is a deprecated alias for `ExaSearchTool`. Existing imports continue to work but emit a deprecation warning; please migrate to `ExaSearchTool`.

View File

@@ -3,12 +3,19 @@ from __future__ import annotations
from builtins import type as type_
import os
from typing import Any, TypedDict
import warnings
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, ConfigDict, Field
from typing_extensions import Required
try:
from exa_py import Exa
except ImportError:
Exa = None # type: ignore[assignment,misc]
class SearchParams(TypedDict, total=False):
"""Parameters for Exa search API."""
@@ -18,7 +25,7 @@ class SearchParams(TypedDict, total=False):
include_domains: list[str]
class EXABaseToolSchema(BaseModel):
class ExaBaseToolSchema(BaseModel):
search_query: str = Field(
..., description="Mandatory search query you want to use to search the internet"
)
@@ -31,14 +38,20 @@ class EXABaseToolSchema(BaseModel):
)
class EXASearchTool(BaseTool):
EXABaseToolSchema = ExaBaseToolSchema
class ExaSearchTool(BaseTool):
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str = "EXASearchTool"
description: str = "Search the internet using Exa"
args_schema: type_[BaseModel] = EXABaseToolSchema
name: str = "ExaSearchTool"
description: str = (
"Search the web with Exa, the fastest and most accurate web search API."
)
args_schema: type_[BaseModel] = ExaBaseToolSchema
client: Any | None = None
content: bool | None = False
summary: bool | None = False
content: bool | dict[str, Any] | None = False
summary: bool | dict[str, Any] | None = False
highlights: bool | dict[str, Any] | None = True
type: str | None = "auto"
package_dependencies: list[str] = Field(default_factory=lambda: ["exa_py"])
api_key: str | None = Field(
@@ -68,17 +81,17 @@ class EXASearchTool(BaseTool):
def __init__(
self,
content: bool | None = False,
summary: bool | None = False,
content: bool | dict[str, Any] | None = False,
summary: bool | dict[str, Any] | None = False,
highlights: bool | dict[str, Any] | None = True,
type: str | None = "auto",
**kwargs: Any,
) -> None:
super().__init__(
**kwargs,
)
try:
from exa_py import Exa
except ImportError as e:
global Exa
if Exa is None:
import click
if click.confirm(
@@ -88,12 +101,13 @@ class EXASearchTool(BaseTool):
subprocess.run(["uv", "add", "exa_py"], check=True) # noqa: S607
# Re-import after installation
from exa_py import Exa
from exa_py import Exa as _Exa
Exa = _Exa # type: ignore[misc]
else:
raise ImportError(
"You are missing the 'exa_py' package. Would you like to install it?"
) from e
"You are missing the 'exa_py' package. Please install it to use ExaSearchTool."
)
client_kwargs: dict[str, str] = {}
if self.api_key:
@@ -101,8 +115,10 @@ class EXASearchTool(BaseTool):
if self.base_url:
client_kwargs["base_url"] = self.base_url
self.client = Exa(**client_kwargs)
self.client.headers["x-exa-integration"] = "crewai"
self.content = content
self.summary = summary
self.highlights = highlights
self.type = type
def _run(
@@ -126,10 +142,31 @@ class EXASearchTool(BaseTool):
if include_domains:
search_params["include_domains"] = include_domains
contents_kwargs: dict[str, Any] = {}
if self.content:
results = self.client.search_and_contents(
search_query, summary=self.summary, **search_params
contents_kwargs["text"] = self.content
if self.highlights:
contents_kwargs["highlights"] = self.highlights
if self.summary:
contents_kwargs["summary"] = self.summary
if contents_kwargs:
return self.client.search_and_contents(
search_query, **contents_kwargs, **search_params
)
else:
results = self.client.search(search_query, **search_params)
return results
return self.client.search(search_query, **search_params)
class EXASearchTool(ExaSearchTool):
"""Deprecated alias for :class:`ExaSearchTool`. Kept for backwards compatibility."""
name: str = "ExaSearchTool"
def __init__(self, *args: Any, **kwargs: Any) -> None:
warnings.warn(
"EXASearchTool is deprecated and will be removed in a future release; "
"use ExaSearchTool instead.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)

View File

@@ -1,13 +1,13 @@
import os
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from crewai_tools import EXASearchTool
from crewai_tools import EXASearchTool, ExaSearchTool
import pytest
@pytest.fixture
def exa_search_tool():
return EXASearchTool(api_key="test_api_key")
return ExaSearchTool(api_key="test_api_key")
@pytest.fixture(autouse=True)
@@ -22,11 +22,12 @@ def test_exa_search_tool_initialization():
"crewai_tools.tools.exa_tools.exa_search_tool.Exa"
) as mock_exa_class:
api_key = "test_api_key"
tool = EXASearchTool(api_key=api_key)
tool = ExaSearchTool(api_key=api_key)
assert tool.api_key == api_key
assert tool.content is False
assert tool.summary is False
assert tool.highlights is True
assert tool.type == "auto"
mock_exa_class.assert_called_once_with(api_key=api_key)
@@ -36,7 +37,7 @@ def test_exa_search_tool_initialization_with_env(mock_exa_api_key):
with patch(
"crewai_tools.tools.exa_tools.exa_search_tool.Exa"
) as mock_exa_class:
EXASearchTool()
ExaSearchTool()
mock_exa_class.assert_called_once_with(api_key="test_key_from_env")
@@ -47,12 +48,13 @@ def test_exa_search_tool_initialization_with_base_url():
) as mock_exa_class:
api_key = "test_api_key"
base_url = "https://custom.exa.api.com"
tool = EXASearchTool(api_key=api_key, base_url=base_url)
tool = ExaSearchTool(api_key=api_key, base_url=base_url)
assert tool.api_key == api_key
assert tool.base_url == base_url
assert tool.content is False
assert tool.summary is False
assert tool.highlights is True
assert tool.type == "auto"
mock_exa_class.assert_called_once_with(api_key=api_key, base_url=base_url)
@@ -67,7 +69,7 @@ def test_exa_search_tool_initialization_with_env_base_url(
mock_exa_api_key, mock_exa_base_url
):
with patch("crewai_tools.tools.exa_tools.exa_search_tool.Exa") as mock_exa_class:
EXASearchTool()
ExaSearchTool()
mock_exa_class.assert_called_once_with(
api_key="test_key_from_env", base_url="https://env.exa.api.com"
)
@@ -79,8 +81,33 @@ def test_exa_search_tool_initialization_without_base_url():
"crewai_tools.tools.exa_tools.exa_search_tool.Exa"
) as mock_exa_class:
api_key = "test_api_key"
tool = EXASearchTool(api_key=api_key)
tool = ExaSearchTool(api_key=api_key)
assert tool.api_key == api_key
assert tool.base_url is None
mock_exa_class.assert_called_once_with(api_key=api_key)
def test_exa_search_tool_highlights_uses_search_and_contents():
with patch("crewai_tools.tools.exa_tools.exa_search_tool.Exa") as mock_exa_class:
mock_client = MagicMock()
mock_exa_class.return_value = mock_client
tool = ExaSearchTool(
api_key="test_api_key", highlights={"max_characters": 4000}
)
tool._run(search_query="hello world")
mock_client.search_and_contents.assert_called_once_with(
"hello world",
highlights={"max_characters": 4000},
type="auto",
)
mock_client.search.assert_not_called()
def test_exasearchtool_alias_is_deprecated():
with patch("crewai_tools.tools.exa_tools.exa_search_tool.Exa"):
with pytest.warns(DeprecationWarning, match="ExaSearchTool"):
tool = EXASearchTool(api_key="test_api_key")
assert isinstance(tool, ExaSearchTool)

View File

@@ -9397,7 +9397,7 @@
}
},
{
"description": "Search the internet using Exa",
"description": "Search the web with Exa, the fastest and most accurate web search API.",
"env_vars": [
{
"default": null,
@@ -9412,7 +9412,7 @@
"required": false
}
],
"humanized_name": "EXASearchTool",
"humanized_name": "ExaSearchTool",
"init_params_schema": {
"$defs": {
"EnvVar": {
@@ -9451,6 +9451,7 @@
"type": "object"
}
},
"description": "Deprecated alias for :class:`ExaSearchTool`. Kept for backwards compatibility.",
"properties": {
"api_key": {
"anyOf": [
@@ -9493,6 +9494,10 @@
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
@@ -9500,11 +9505,31 @@
"default": false,
"title": "Content"
},
"highlights": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": true,
"title": "Highlights"
},
"summary": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
@@ -9586,7 +9611,225 @@
"required": [
"search_query"
],
"title": "EXABaseToolSchema",
"title": "ExaBaseToolSchema",
"type": "object"
}
},
{
"description": "Search the web with Exa, the fastest and most accurate web search API.",
"env_vars": [
{
"default": null,
"description": "API key for Exa services",
"name": "EXA_API_KEY",
"required": false
},
{
"default": null,
"description": "API url for the Exa services",
"name": "EXA_BASE_URL",
"required": false
}
],
"humanized_name": "ExaSearchTool",
"init_params_schema": {
"$defs": {
"EnvVar": {
"properties": {
"default": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Default"
},
"description": {
"title": "Description",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"required": {
"default": true,
"title": "Required",
"type": "boolean"
}
},
"required": [
"name",
"description"
],
"title": "EnvVar",
"type": "object"
}
},
"properties": {
"api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "API key for Exa services",
"required": false,
"title": "Api Key"
},
"base_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "API server url",
"required": false,
"title": "Base Url"
},
"client": {
"anyOf": [
{},
{
"type": "null"
}
],
"default": null,
"title": "Client"
},
"content": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": false,
"title": "Content"
},
"highlights": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": true,
"title": "Highlights"
},
"summary": {
"anyOf": [
{
"type": "boolean"
},
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": false,
"title": "Summary"
},
"type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": "auto",
"title": "Type"
}
},
"required": [],
"title": "ExaSearchTool",
"type": "object"
},
"name": "ExaSearchTool",
"package_dependencies": [
"exa_py"
],
"run_params_schema": {
"properties": {
"end_published_date": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "End date for the search",
"title": "End Published Date"
},
"include_domains": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "List of domains to include in the search",
"title": "Include Domains"
},
"search_query": {
"description": "Mandatory search query you want to use to search the internet",
"title": "Search Query",
"type": "string"
},
"start_published_date": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Start date for the search",
"title": "Start Published Date"
}
},
"required": [
"search_query"
],
"title": "ExaBaseToolSchema",
"type": "object"
}
},

View File

@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.4",
"crewai-tools==1.14.5a2",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.4"
__version__ = "1.14.5a2"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -386,8 +386,7 @@ def _execute_task_with_a2a(
return raw_result
finally:
task.description = original_description
if task.output_pydantic is not None:
task.output_pydantic = original_output_pydantic
task.output_pydantic = original_output_pydantic
task.response_model = original_response_model
@@ -1534,8 +1533,7 @@ async def _aexecute_task_with_a2a(
return raw_result
finally:
task.description = original_description
if task.output_pydantic is not None:
task.output_pydantic = original_output_pydantic
task.output_pydantic = original_output_pydantic
task.response_model = original_response_model

View File

@@ -84,6 +84,7 @@ from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.skills.self_improve.models import SelfImprovementConfig
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.types.callback import SerializableCallable
@@ -190,6 +191,7 @@ class Agent(BaseAgent):
_times_executed: int = PrivateAttr(default=0)
_mcp_resolver: MCPToolResolver | None = PrivateAttr(default=None)
_last_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
_self_improve_collector: Any = PrivateAttr(default=None)
max_execution_time: int | None = Field(
default=None,
description="Maximum execution time for an agent to execute a task",
@@ -320,6 +322,15 @@ class Agent(BaseAgent):
agent_executor: CrewAgentExecutor | AgentExecutor | None = Field(
default=None, description="An instance of the CrewAgentExecutor class."
)
self_improve: bool | SelfImprovementConfig = Field(
default=False,
description=(
"Enable the self-improvement loop. ``True`` uses defaults; pass a "
"``SelfImprovementConfig`` to override (e.g. point ``skills_dir`` at "
"a project-relative path so accepted skills get committed alongside "
"the code). See ``crewai.skills.self_improve``."
),
)
executor_class: Annotated[
type[CrewAgentExecutor] | type[AgentExecutor],
BeforeValidator(_validate_executor_class),
@@ -360,6 +371,13 @@ class Agent(BaseAgent):
self.set_skills()
if self.self_improve and self._self_improve_collector is None:
from crewai.skills.self_improve.collector import TraceCollector
collector = TraceCollector(self)
collector.attach(crewai_event_bus)
self._self_improve_collector = collector
if self.reasoning and self.planning_config is None:
warnings.warn(
"The 'reasoning' parameter is deprecated. Use 'planning_config=PlanningConfig()' instead.",
@@ -372,6 +390,14 @@ class Agent(BaseAgent):
return self
def _self_improve_config(self) -> SelfImprovementConfig | None:
"""Return the active SelfImprovementConfig, or None when disabled."""
if not self.self_improve:
return None
if isinstance(self.self_improve, SelfImprovementConfig):
return self.self_improve
return SelfImprovementConfig()
@property
def planning_enabled(self) -> bool:
"""Check if planning is enabled for this agent."""
@@ -429,7 +455,20 @@ class Agent(BaseAgent):
else:
crew_skills = list(resolved_crew_skills)
if not self.skills and not crew_skills:
self_improve_dir: Path | None = None
if (config := self._self_improve_config()) is not None:
from crewai.skills.self_improve.storage import SkillStore, _slug
if config.skills_dir is not None:
candidate = config.skills_dir / _slug(self.role)
else:
candidate = SkillStore().role_dir(self.role)
if candidate.is_dir() and any(
(c / "SKILL.md").is_file() for c in candidate.iterdir() if c.is_dir()
):
self_improve_dir = candidate
if not self.skills and not crew_skills and self_improve_dir is None:
return
needs_work = self.skills and any(
@@ -437,7 +476,7 @@ class Agent(BaseAgent):
or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS)
for s in self.skills
)
if not needs_work and not crew_skills:
if not needs_work and not crew_skills and self_improve_dir is None:
return
seen: set[str] = set()
@@ -447,6 +486,9 @@ class Agent(BaseAgent):
if crew_skills:
items.extend(crew_skills)
if self_improve_dir is not None:
items.append(self_improve_dir)
for item in items:
if isinstance(item, Path):
discovered = discover_skills(item, source=self)
@@ -1102,16 +1144,6 @@ class Agent(BaseAgent):
self.agent_executor.tools_handler = self.tools_handler
self.agent_executor.request_within_rpm_limit = rpm_limit_fn
if isinstance(self.agent_executor.llm, BaseLLM):
existing_stop = getattr(self.agent_executor.llm, "stop", [])
self.agent_executor.llm.stop = list(
set(
existing_stop + stop_words
if isinstance(existing_stop, list)
else stop_words
)
)
def get_delegation_tools(self, agents: Sequence[BaseAgent]) -> list[BaseTool]:
agent_tools = AgentTools(agents=agents)
return agent_tools.tools()

View File

@@ -49,6 +49,7 @@ from crewai.hooks.tool_hooks import (
)
from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
aget_llm_response,
convert_tools_to_openai_schema,
enforce_rpm_limit,
@@ -141,15 +142,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
if not self.after_llm_call_hooks:
self.after_llm_call_hooks.extend(get_after_llm_call_hooks())
if self.llm and not isinstance(self.llm, str):
existing_stop = getattr(self.llm, "stop", [])
self.llm.stop = list(
set(
existing_stop + self.stop
if isinstance(existing_stop, list)
else self.stop
)
)
@property
def use_stop_words(self) -> bool:
@@ -210,21 +202,22 @@ class CrewAgentExecutor(BaseAgentExecutor):
self.ask_for_human_input = bool(inputs.get("ask_for_human_input", False))
try:
formatted_answer = self._invoke_loop()
except AssertionError:
if self.agent.verbose:
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
with _llm_stop_words_applied(self.llm, self):
try:
formatted_answer = self._invoke_loop()
except AssertionError:
if self.agent.verbose:
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
if self.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
if self.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}
@@ -1082,21 +1075,22 @@ class CrewAgentExecutor(BaseAgentExecutor):
self.ask_for_human_input = bool(inputs.get("ask_for_human_input", False))
try:
formatted_answer = await self._ainvoke_loop()
except AssertionError:
if self.agent.verbose:
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
with _llm_stop_words_applied(self.llm, self):
try:
formatted_answer = await self._ainvoke_loop()
except AssertionError:
if self.agent.verbose:
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
if self.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(formatted_answer)
if self.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(formatted_answer)
self._save_to_memory(formatted_answer)
return {"output": formatted_answer.output}

View File

@@ -24,6 +24,7 @@ from crewai.cli.reset_memories_command import reset_memories_command
from crewai.cli.run_crew import run_crew
from crewai.cli.settings.main import SettingsCommand
from crewai.cli.shared.token_manager import TokenManager
from crewai.cli.skills_proposals import skills as skills_group
from crewai.cli.tools.main import ToolCommand
from crewai.cli.train_crew import train_crew
from crewai.cli.triggers.main import TriggersCommand
@@ -955,5 +956,8 @@ def checkpoint_prune(
prune_checkpoints(ctx.obj["location"], keep, older_than, dry_run)
crewai.add_command(skills_group)
if __name__ == "__main__":
crewai()

View File

@@ -0,0 +1,147 @@
"""Minimal Textual TUI for triaging skill proposals.
Two panes: the proposals list on the left, the highlighted proposal's
``SKILL.md`` body on the right. Keystrokes accept/reject in place. No
search, no scopes, no async workers — the underlying actions are the
same `accept_proposal` / `reject_proposal` calls the CLI uses.
"""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Footer, Header, OptionList, Static
from crewai.skills.self_improve import (
ProposalStore,
accept_proposal,
reject_proposal,
)
from crewai.skills.self_improve.models import SkillProposal
_PRIMARY = "#eb6658"
_SECONDARY = "#1F7982"
_TERTIARY = "#ffffff"
def _format_proposal_detail(p: SkillProposal) -> str:
kind = (
f"[bold]{p.proposal_kind}[/] → {p.target_skill}"
if p.proposal_kind == "patch_existing"
else "[bold]new[/]"
)
runs = ", ".join(p.derived_from_runs) or "-"
return (
f"[bold {_PRIMARY}]{p.name}[/]\n"
f"[dim]role:[/] {p.agent_role}\n"
f"[dim]kind:[/] {kind}\n"
f"[dim]confidence:[/] [bold]{p.confidence:.2f}[/]\n"
f"[dim]from runs:[/] {runs}\n\n"
f"[bold]Rationale[/]\n{p.rationale}\n\n"
f"[bold {_PRIMARY}]{'' * 44}[/]\n"
f"{p.body}"
)
class SkillProposalsTUI(App[None]):
"""Triage UI: navigate the queue, accept or reject in place."""
TITLE = "CrewAI Skill Proposals"
SUB_TITLE = "↑↓ list · tab focus pane · PgUp/PgDn or mouse to scroll body · a/r/q"
BINDINGS = [
Binding("a", "accept", "Accept"),
Binding("r", "reject", "Reject"),
Binding("q", "quit", "Quit"),
Binding("tab", "focus_next", "Switch pane", show=False),
]
CSS = f"""
Header {{ background: {_PRIMARY}; color: {_TERTIARY}; }}
Footer {{ background: {_SECONDARY}; color: {_TERTIARY}; }}
Footer > .footer-key--key {{ background: {_PRIMARY}; color: {_TERTIARY}; }}
Horizontal {{ height: 1fr; }}
#list {{
width: 40%;
border-right: solid {_SECONDARY};
scrollbar-color: {_PRIMARY};
}}
#list > .option-list--option-highlighted {{
background: {_SECONDARY}; color: {_TERTIARY};
}}
#detail-scroll {{
width: 60%;
padding: 1 2;
scrollbar-color: {_PRIMARY};
}}
#detail-scroll:focus {{
background: {_SECONDARY} 5%;
}}
"""
def __init__(self, store: ProposalStore | None = None) -> None:
super().__init__()
self._store = store or ProposalStore()
self._proposals: list[SkillProposal] = []
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
with Horizontal():
yield OptionList(id="list")
with VerticalScroll(id="detail-scroll"):
yield Static("Select a proposal to view its body.", id="detail")
yield Footer()
def on_mount(self) -> None:
self.query_one("#list", OptionList).border_title = "Pending"
self.query_one("#detail-scroll", VerticalScroll).border_title = "Detail"
self._reload()
def _reload(self) -> None:
self._proposals = self._store.list_all()
option_list = self.query_one("#list", OptionList)
option_list.clear_options()
for p in self._proposals:
kind_tag = "P" if p.proposal_kind == "patch_existing" else "N"
label = f"[{kind_tag}] {p.confidence:.2f} {p.name}"
option_list.add_option(label)
option_list.border_title = f"Pending ({len(self._proposals)})"
if not self._proposals:
self.query_one("#detail", Static).update("[dim](queue is empty)[/]")
def _selected(self) -> SkillProposal | None:
idx = self.query_one("#list", OptionList).highlighted
if idx is None or idx >= len(self._proposals):
return None
return self._proposals[idx]
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
idx = event.option_index
if idx < len(self._proposals):
self.query_one("#detail", Static).update(
_format_proposal_detail(self._proposals[idx])
)
def action_accept(self) -> None:
prop = self._selected()
if prop is None:
return
try:
accept_proposal(prop)
self.notify(f"Accepted: {prop.name}", severity="information")
except FileExistsError as e:
self.notify(str(e), severity="warning", timeout=8)
return
self._reload()
def action_reject(self) -> None:
prop = self._selected()
if prop is None:
return
reject_proposal(prop)
self.notify(f"Rejected: {prop.name}", severity="information")
self._reload()

View File

@@ -0,0 +1,249 @@
"""``crewai skills`` subcommands for the self-improvement loop."""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING
import click
from crewai.skills.self_improve import (
ProposalStore,
SkillReviewer,
TraceStore,
accept_proposal,
reject_proposal,
)
if TYPE_CHECKING:
from crewai.skills.self_improve.models import RunTrace, SkillProposal
_DEFAULT_REVIEW_MODEL = "anthropic/claude-haiku-4-5"
def _print_proposal_summary(p: SkillProposal) -> None:
kind = f"PATCH→{p.target_skill}" if p.proposal_kind == "patch_existing" else "NEW"
click.echo(f" {p.id} {kind:<28} conf={p.confidence:.2f} role={p.agent_role}")
click.echo(f" {p.name}: {p.description}")
@click.group(name="skills")
def skills() -> None:
"""Manage agent skills."""
@skills.command(name="review")
@click.option(
"--role",
default=None,
help="Limit review to one role (slug or full name). Default: all roles with traces.",
)
@click.option(
"--model",
default=_DEFAULT_REVIEW_MODEL,
help=f"LiteLLM model id for the reviewer LLM. Default: {_DEFAULT_REVIEW_MODEL}.",
)
@click.option(
"--min-traces",
default=2,
type=int,
help="Minimum traces per role before review fires. Default: 2.",
)
@click.option(
"--floor",
default=0.6,
type=float,
help="Drop proposals below this confidence. Default: 0.6.",
)
def skills_review(role: str | None, model: str, min_traces: int, floor: float) -> None:
"""Mine accumulated traces for skill proposals.
Reads role + goal from the trace metadata, calls the reviewer LLM, and
persists each proposal that scores above ``--floor`` to the queue. Use
``crewai skills proposals list`` to see what came out.
"""
from crewai import LLM
trace_store = TraceStore()
proposal_store = ProposalStore()
if not trace_store.root.exists():
click.echo(f"No traces yet at {trace_store.root}", err=True)
raise SystemExit(1)
# Group traces by role from disk; the role-slug dirs come from the store.
by_role: dict[str, list[RunTrace]] = defaultdict(list)
role_dirs = (
[trace_store.role_dir(role)] if role else sorted(trace_store.root.iterdir())
)
for d in role_dirs:
if not d.is_dir():
continue
for path in sorted(d.glob("*.json")):
t = trace_store.load(path)
by_role[t.agent_role].append(t)
if not by_role:
click.echo(
"No traces found." if role is None else f"No traces for role={role!r}."
)
return
reviewer_llm = LLM(model=model)
total_emitted = 0
for agent_role, traces in by_role.items():
if len(traces) < min_traces:
click.echo(
f"Skipping {agent_role!r}: {len(traces)} trace(s), need {min_traces}."
)
continue
agent_goal = next((t.agent_goal for t in traces if t.agent_goal), "")
loaded_skills_seen = sorted({s for t in traces for s in t.loaded_skills})
pending = [
proposal_store.load(p) for p in proposal_store.list_for_role(agent_role)
]
reviewer = SkillReviewer(
agent_role=agent_role,
agent_goal=agent_goal,
llm=reviewer_llm,
min_traces=min_traces,
confidence_floor=floor,
)
click.echo(
f"🧠 Reviewing {len(traces)} trace(s) for {agent_role!r} "
f"(model={model}, pending={len(pending)})…"
)
proposals_out = reviewer.review(
traces,
loaded_skill_names=loaded_skills_seen,
pending_proposals=pending,
)
for p in proposals_out:
path = proposal_store.save(p)
click.echo(f" + {p.id} conf={p.confidence:.2f} {p.name}")
click.echo(f"{path}")
total_emitted += len(proposals_out)
click.echo(
f"\n✅ Done. {total_emitted} proposal(s) added to the queue. "
f"Run `crewai skills proposals list` to view."
)
@skills.group(name="proposals")
def proposals() -> None:
"""Manage skill proposals from the self-improvement reviewer."""
@proposals.command(name="list")
@click.option("--role", default=None, help="Filter by agent role (slug or full name).")
def proposals_list(role: str | None) -> None:
"""List pending proposals across all roles."""
store = ProposalStore()
if role:
records = [store.load(p) for p in store.list_for_role(role)]
else:
records = store.list_all()
if not records:
click.echo("(no pending proposals)")
return
click.echo(f"{len(records)} proposal(s):")
for p in records:
_print_proposal_summary(p)
@proposals.command(name="show")
@click.argument("proposal_id")
def proposals_show(proposal_id: str) -> None:
"""Print the full body of a proposal."""
store = ProposalStore()
prop = store.find(proposal_id)
if prop is None:
click.echo(f"No proposal with id {proposal_id!r}", err=True)
raise SystemExit(1)
click.echo(f"id: {prop.id}")
click.echo(f"role: {prop.agent_role}")
click.echo(f"name: {prop.name}")
click.echo(f"description: {prop.description}")
click.echo(f"confidence: {prop.confidence:.2f}")
click.echo(f"kind: {prop.proposal_kind}")
if prop.target_skill:
click.echo(f"target: {prop.target_skill}")
click.echo(f"derived from: {', '.join(prop.derived_from_runs)}")
click.echo("\nrationale:")
click.echo(prop.rationale)
click.echo("\n--- SKILL.md body ---")
click.echo(prop.body)
@proposals.command(name="accept")
@click.argument("proposal_id")
@click.option(
"--force", is_flag=True, help="Overwrite an existing skill of the same name."
)
@click.option(
"--skills-dir",
type=click.Path(file_okay=False, path_type=Path),
default=None,
envvar="CREWAI_SELF_IMPROVE_SKILLS_DIR",
help=(
"Directory to write the SKILL.md to. Defaults to the env var "
"CREWAI_SELF_IMPROVE_SKILLS_DIR, then to the platform data dir. "
"Use a project-relative path (e.g. ./skills/learned) to keep "
"accepted skills under version control — and pass the same path "
"to Agent(self_improve=SelfImprovementConfig(skills_dir=...)) so "
"the agent loads it on the next kickoff."
),
)
def proposals_accept(proposal_id: str, force: bool, skills_dir: Path | None) -> None:
"""Materialize a proposal as a live SKILL.md."""
from crewai.skills.self_improve import SkillStore
store = ProposalStore()
prop = store.find(proposal_id)
if prop is None:
click.echo(f"No proposal with id {proposal_id!r}", err=True)
raise SystemExit(1)
skill_store = SkillStore(skills_root=skills_dir) if skills_dir else None
try:
path = accept_proposal(prop, force=force, skill_store=skill_store)
except FileExistsError as e:
click.echo(f"{e}", err=True)
raise SystemExit(2) from None
click.echo(f"✅ Accepted: {path}")
click.echo(
" This skill will load on the next kickoff for any agent with "
f"role={prop.agent_role!r} and self_improve enabled "
"(make sure SelfImprovementConfig.skills_dir matches if you used --skills-dir)."
)
@proposals.command(name="reject")
@click.argument("proposal_id")
def proposals_reject(proposal_id: str) -> None:
"""Drop a proposal from the queue without accepting."""
store = ProposalStore()
prop = store.find(proposal_id)
if prop is None:
click.echo(f"No proposal with id {proposal_id!r}", err=True)
raise SystemExit(1)
reject_proposal(prop)
click.echo(f"🗑 Rejected: {prop.id}")
@proposals.command(name="tui")
def proposals_tui() -> None:
"""Open an interactive triage TUI for the proposals queue."""
from crewai.cli.skill_proposals_tui import SkillProposalsTUI
SkillProposalsTUI().run()

View File

@@ -774,7 +774,7 @@ def calculator(expression: str) -> str:
```
### Built-in Tools (install with `uv add crewai-tools`)
Web/Search: SerperDevTool, ScrapeWebsiteTool, WebsiteSearchTool, EXASearchTool, FirecrawlSearchTool
Web/Search: SerperDevTool, ScrapeWebsiteTool, WebsiteSearchTool, ExaSearchTool, FirecrawlSearchTool
Documents: FileReadTool, DirectoryReadTool, PDFSearchTool, DOCXSearchTool, CSVSearchTool, JSONSearchTool, XMLSearchTool, MDXSearchTool
Code: CodeInterpreterTool, CodeDocsSearchTool, GithubSearchTool
Media: DALL-E Tool, YoutubeChannelSearchTool, YoutubeVideoSearchTool

View File

@@ -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.4"
"crewai[tools]==1.14.5a2"
]
[project.scripts]

View File

@@ -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.4"
"crewai[tools]==1.14.5a2"
]
[project.scripts]

View File

@@ -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.4"
"crewai[tools]==1.14.5a2"
]
[tool.crewai]

View File

@@ -1283,8 +1283,8 @@ class Crew(FlowTrackable, BaseModel):
pending_tasks.append((task, async_task, task_index))
else:
if pending_tasks:
task_outputs = await self._aprocess_async_tasks(
pending_tasks, was_replayed
task_outputs.extend(
await self._aprocess_async_tasks(pending_tasks, was_replayed)
)
pending_tasks.clear()
@@ -1299,7 +1299,9 @@ class Crew(FlowTrackable, BaseModel):
self._store_execution_log(task, task_output, task_index, was_replayed)
if pending_tasks:
task_outputs = await self._aprocess_async_tasks(pending_tasks, was_replayed)
task_outputs.extend(
await self._aprocess_async_tasks(pending_tasks, was_replayed)
)
return self._create_crew_output(task_outputs)
@@ -1313,7 +1315,9 @@ class Crew(FlowTrackable, BaseModel):
) -> TaskOutput | None:
"""Handle conditional task evaluation using native async."""
if pending_tasks:
task_outputs = await self._aprocess_async_tasks(pending_tasks, was_replayed)
task_outputs.extend(
await self._aprocess_async_tasks(pending_tasks, was_replayed)
)
pending_tasks.clear()
return check_conditional_skip(
@@ -1489,7 +1493,9 @@ class Crew(FlowTrackable, BaseModel):
futures.append((task, future, task_index))
else:
if futures:
task_outputs = self._process_async_tasks(futures, was_replayed)
task_outputs.extend(
self._process_async_tasks(futures, was_replayed)
)
futures.clear()
context = self._get_context(task, task_outputs)
@@ -1503,7 +1509,7 @@ class Crew(FlowTrackable, BaseModel):
self._store_execution_log(task, task_output, task_index, was_replayed)
if futures:
task_outputs = self._process_async_tasks(futures, was_replayed)
task_outputs.extend(self._process_async_tasks(futures, was_replayed))
return self._create_crew_output(task_outputs)
@@ -1516,7 +1522,7 @@ class Crew(FlowTrackable, BaseModel):
was_replayed: bool,
) -> TaskOutput | None:
if futures:
task_outputs = self._process_async_tasks(futures, was_replayed)
task_outputs.extend(self._process_async_tasks(futures, was_replayed))
futures.clear()
return check_conditional_skip(

View File

@@ -108,6 +108,13 @@ from crewai.events.types.reasoning_events import (
AgentReasoningFailedEvent,
AgentReasoningStartedEvent,
)
from crewai.events.types.skill_events import (
SkillActivatedEvent,
SkillDiscoveryCompletedEvent,
SkillDiscoveryStartedEvent,
SkillLoadFailedEvent,
SkillLoadedEvent,
)
from crewai.events.types.system_events import SignalEvent, on_signal
from crewai.events.types.task_events import (
TaskCompletedEvent,
@@ -530,6 +537,30 @@ class TraceCollectionListener(BaseEventListener):
) -> None:
self._handle_action_event("knowledge_query_failed", source, event)
@event_bus.on(SkillDiscoveryStartedEvent)
def on_skill_discovery_started(
source: Any, event: SkillDiscoveryStartedEvent
) -> None:
self._handle_action_event("skill_discovery_started", source, event)
@event_bus.on(SkillDiscoveryCompletedEvent)
def on_skill_discovery_completed(
source: Any, event: SkillDiscoveryCompletedEvent
) -> None:
self._handle_action_event("skill_discovery_completed", source, event)
@event_bus.on(SkillLoadedEvent)
def on_skill_loaded(source: Any, event: SkillLoadedEvent) -> None:
self._handle_action_event("skill_loaded", source, event)
@event_bus.on(SkillActivatedEvent)
def on_skill_activated(source: Any, event: SkillActivatedEvent) -> None:
self._handle_action_event("skill_activated", source, event)
@event_bus.on(SkillLoadFailedEvent)
def on_skill_load_failed(source: Any, event: SkillLoadFailedEvent) -> None:
self._handle_action_event("skill_load_failed", source, event)
def _register_a2a_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
"""Register handlers for A2A (Agent-to-Agent) events."""

View File

@@ -71,6 +71,7 @@ from crewai.hooks.types import (
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
check_native_tool_support,
enforce_rpm_limit,
extract_tool_call_info,
@@ -215,12 +216,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
self.after_llm_call_hooks.extend(get_after_llm_call_hooks())
if self.llm:
existing_stop = getattr(self.llm, "stop", [])
if not isinstance(existing_stop, list):
existing_stop = []
self.llm.stop = list(set(existing_stop + self.stop_words))
self._state = AgentExecutorState()
self.max_method_calls = self.max_iter * 10
@@ -2601,17 +2596,18 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
inputs.get("ask_for_human_input", False)
)
self.kickoff()
with _llm_stop_words_applied(self.llm, self):
self.kickoff()
formatted_answer = self.state.current_answer
formatted_answer = self.state.current_answer
if not isinstance(formatted_answer, AgentFinish):
raise RuntimeError(
"Agent execution ended without reaching a final answer."
)
if not isinstance(formatted_answer, AgentFinish):
raise RuntimeError(
"Agent execution ended without reaching a final answer."
)
if self.state.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
if self.state.ask_for_human_input:
formatted_answer = self._handle_human_feedback(formatted_answer)
self._save_to_memory(formatted_answer)
@@ -2691,18 +2687,20 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
inputs.get("ask_for_human_input", False)
)
# Use async kickoff directly since we're already in an async context
await self.kickoff_async()
with _llm_stop_words_applied(self.llm, self):
await self.kickoff_async()
formatted_answer = self.state.current_answer
formatted_answer = self.state.current_answer
if not isinstance(formatted_answer, AgentFinish):
raise RuntimeError(
"Agent execution ended without reaching a final answer."
)
if not isinstance(formatted_answer, AgentFinish):
raise RuntimeError(
"Agent execution ended without reaching a final answer."
)
if self.state.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(formatted_answer)
if self.state.ask_for_human_input:
formatted_answer = await self._ahandle_human_feedback(
formatted_answer
)
self._save_to_memory(formatted_answer)

View File

@@ -1074,7 +1074,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
_human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict)
_input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list)
_state: Any = PrivateAttr(default=None)
_execution_id: str = PrivateAttr(default_factory=lambda: str(uuid4()))
def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override]
class _FlowGeneric(cls): # type: ignore[valid-type,misc]
@@ -1865,27 +1864,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
except (AttributeError, TypeError):
return "" # Safely handle any unexpected attribute access issues
@property
def execution_id(self) -> str:
"""Stable identifier for this flow execution.
Separate from ``flow_id`` / ``state.id``, which consumers may
override via ``kickoff(inputs={"id": ...})`` to resume a persisted
flow. ``execution_id`` is never affected by ``inputs`` and stays
stable for the lifetime of a single run, so it is the correct key
for telemetry, tracing, and any external correlation that must
uniquely identify a single execution even when callers pass an
``id`` in ``inputs``.
Defaults to a fresh ``uuid4`` per ``Flow`` instance; assign to
override when an outer system already has an execution identity.
"""
return self._execution_id
@execution_id.setter
def execution_id(self, value: str) -> None:
self._execution_id = value
def _initialize_state(self, inputs: dict[str, Any]) -> None:
"""Initialize or update flow state with new inputs.
@@ -2054,6 +2032,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
"""Start the flow execution in a synchronous context.
@@ -2065,10 +2044,24 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
input_files: Optional dict of named file inputs for the flow.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the flow resumes from that checkpoint.
restore_from_state_id: Optional UUID of a previously-persisted flow
whose latest snapshot should hydrate this run's state. The new
run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if
pinned), so its ``@persist`` writes land under a separate
persistence key and the source flow's history is preserved.
If the referenced state is not found, the kickoff falls back
silently to baseline behavior. Cannot be combined with
``from_checkpoint``; passing both raises ``ValueError``.
Returns:
The final output from the flow or FlowStreamingOutput if streaming.
"""
if from_checkpoint is not None and restore_from_state_id is not None:
raise ValueError(
"Cannot combine `from_checkpoint` and `restore_from_state_id`. "
"These parameters target different state systems "
"(Checkpointing and @persist) and cannot be used together."
)
restored = apply_checkpoint(self, from_checkpoint)
if restored is not None:
return restored.kickoff(inputs=inputs, input_files=input_files)
@@ -2090,7 +2083,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
def run_flow() -> None:
try:
self.stream = False
result = self.kickoff(inputs=inputs, input_files=input_files)
result = self.kickoff(
inputs=inputs,
input_files=input_files,
restore_from_state_id=restore_from_state_id,
)
result_holder.append(result)
except Exception as e:
# HumanFeedbackPending is expected control flow, not an error
@@ -2113,7 +2110,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
return streaming_output
async def _run_flow() -> Any:
return await self.kickoff_async(inputs, input_files)
return await self.kickoff_async(
inputs,
input_files,
restore_from_state_id=restore_from_state_id,
)
try:
asyncio.get_running_loop()
@@ -2128,6 +2129,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
"""Start the flow execution asynchronously.
@@ -2141,10 +2143,23 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
input_files: Optional dict of named file inputs for the flow.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the flow resumes from that checkpoint.
restore_from_state_id: Optional UUID of a previously-persisted flow
whose latest snapshot should hydrate this run's state. The new
run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if
pinned), so subsequent ``@persist`` writes land under a
separate persistence key. If the referenced state is not
found, falls back silently to baseline. Cannot be combined
with ``from_checkpoint``; passing both raises ``ValueError``.
Returns:
The final output from the flow, which is the result of the last executed method.
"""
if from_checkpoint is not None and restore_from_state_id is not None:
raise ValueError(
"Cannot combine `from_checkpoint` and `restore_from_state_id`. "
"These parameters target different state systems "
"(Checkpointing and @persist) and cannot be used together."
)
restored = apply_checkpoint(self, from_checkpoint)
if restored is not None:
return await restored.kickoff_async(inputs=inputs, input_files=input_files)
@@ -2167,7 +2182,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
try:
self.stream = False
result = await self.kickoff_async(
inputs=inputs, input_files=input_files
inputs=inputs,
input_files=input_files,
restore_from_state_id=restore_from_state_id,
)
result_holder.append(result)
except Exception as e:
@@ -2199,9 +2216,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
flow_id_token = None
request_id_token = None
if current_flow_id.get() is None:
flow_id_token = current_flow_id.set(self.execution_id)
flow_id_token = current_flow_id.set(self.flow_id)
if current_flow_request_id.get() is None:
request_id_token = current_flow_request_id.set(self.execution_id)
request_id_token = current_flow_request_id.set(self.flow_id)
try:
# Reset flow state for fresh execution unless restoring from persistence
@@ -2224,16 +2241,54 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
if self._completed_methods:
self._is_execution_resuming = True
# Fork hydration: when restore_from_state_id is set and persistence is
# available, hydrate self._state from the source UUID's latest snapshot
# and reassign state.id to a fresh value so subsequent @persist writes
# don't extend the source flow's history. If the source state is not
# found, fall through silently to the existing inputs handling.
fork_succeeded = False
if restore_from_state_id is not None and self.persistence is not None:
stored_state = self.persistence.load_state(restore_from_state_id)
if stored_state:
self._log_flow_event(
f"Forking flow state from UUID: {restore_from_state_id}"
)
self._restore_state(stored_state)
# Pin to inputs["id"] when provided, otherwise mint a fresh
# UUID. NOTE: pinning inputs.id while forking shares a
# persistence key with another flow — usually you want only
# restore_from_state_id.
new_state_id = (inputs.get("id") if inputs else None) or str(
uuid4()
)
if isinstance(self._state, dict):
self._state["id"] = new_state_id
elif isinstance(self._state, BaseModel):
setattr(self._state, "id", new_state_id) # noqa: B010
fork_succeeded = True
else:
self._log_flow_event(
"No flow state found for restore_from_state_id: "
f"{restore_from_state_id}; proceeding without hydration",
color="yellow",
)
if inputs:
# Override the id in the state if it exists in inputs
if "id" in inputs:
# Override the id in the state if it exists in inputs.
# Skip when the fork already assigned state.id above.
if "id" in inputs and not fork_succeeded:
if isinstance(self._state, dict):
self._state["id"] = inputs["id"]
elif isinstance(self._state, BaseModel):
setattr(self._state, "id", inputs["id"]) # noqa: B010
# If persistence is enabled, attempt to restore the stored state using the provided id.
if "id" in inputs and self.persistence is not None:
# Skip when the fork already restored self._state above.
if (
"id" in inputs
and self.persistence is not None
and not fork_succeeded
):
restore_uuid = inputs["id"]
stored_state = self.persistence.load_state(restore_uuid)
if stored_state:
@@ -2416,6 +2471,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
inputs: dict[str, Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
restore_from_state_id: str | None = None,
) -> Any | FlowStreamingOutput:
"""Native async method to start the flow execution. Alias for kickoff_async.
@@ -2424,11 +2480,19 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
input_files: Optional dict of named file inputs for the flow.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the flow resumes from that checkpoint.
restore_from_state_id: Optional UUID of a previously-persisted flow
whose latest snapshot should hydrate this run's state. See
``kickoff_async`` for full semantics.
Returns:
The final output from the flow, which is the result of the last executed method.
"""
return await self.kickoff_async(inputs, input_files, from_checkpoint)
return await self.kickoff_async(
inputs,
input_files,
from_checkpoint,
restore_from_state_id=restore_from_state_id,
)
async def _replay_recorded_events(self) -> None:
"""Dispatch recorded ``MethodExecution*`` events from the event record."""

View File

@@ -688,7 +688,9 @@ class LLM(BaseLLM):
"temperature": self.temperature,
"top_p": self.top_p,
"n": self.n,
"stop": (self.stop or None) if self.supports_stop_words() else None,
"stop": (self.stop_sequences or None)
if self.supports_stop_words()
else None,
"max_tokens": self.max_tokens or self.max_completion_tokens,
"presence_penalty": self.presence_penalty,
"frequency_penalty": self.frequency_penalty,

View File

@@ -72,6 +72,9 @@ _JSON_EXTRACTION_PATTERN: Final[re.Pattern[str]] = re.compile(r"\{.*}", re.DOTAL
_current_call_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"_current_call_id", default=None
)
_call_stop_override_var: contextvars.ContextVar[dict[int, list[str]] | None] = (
contextvars.ContextVar("_call_stop_override_var", default=None)
)
@contextmanager
@@ -85,6 +88,31 @@ def llm_call_context() -> Generator[str, None, None]:
_current_call_id.reset(token)
@contextmanager
def call_stop_override(
llm: BaseLLM, stop: list[str] | None
) -> Generator[None, None, None]:
"""Override the stop list for ``llm`` within the current call scope.
Only ``llm``'s reads via :attr:`BaseLLM.stop_sequences` see ``stop``;
other LLM instances (e.g. an agent's ``function_calling_llm``) keep their
own ``stop`` field. Passing ``None`` clears any prior override for ``llm``
in the same scope. The instance-level ``stop`` field is never mutated,
so the override is safe under concurrent execution.
"""
current = _call_stop_override_var.get()
new_overrides: dict[int, list[str]] = dict(current) if current else {}
if stop is None:
new_overrides.pop(id(llm), None)
else:
new_overrides[id(llm)] = stop
token = _call_stop_override_var.set(new_overrides)
try:
yield
finally:
_call_stop_override_var.reset(token)
def get_current_call_id() -> str:
"""Get current call_id from context"""
call_id = _current_call_id.get()
@@ -158,11 +186,18 @@ class BaseLLM(BaseModel, ABC):
@property
def stop_sequences(self) -> list[str]:
"""Alias for ``stop`` — kept for backward compatibility with provider APIs.
"""Stop list active for the current call.
Writes are handled by ``__setattr__``, which normalizes and redirects
``stop_sequences`` assignments to the ``stop`` field.
Returns the per-instance override set via :func:`call_stop_override`
when one is in effect for this LLM; otherwise the instance-level
``stop`` field. Kept under this name for backward compatibility with
provider APIs that already read ``stop_sequences``.
"""
overrides = _call_stop_override_var.get()
if overrides is not None:
override = overrides.get(id(self))
if override is not None:
return override
return self.stop
_token_usage: dict[str, int] = PrivateAttr(
@@ -341,7 +376,7 @@ class BaseLLM(BaseModel, ABC):
Returns:
True if stop words are configured and can be applied
"""
return bool(self.stop)
return bool(self.stop_sequences)
def _apply_stop_words(self, content: str) -> str:
"""Apply stop words to truncate response content.
@@ -363,14 +398,14 @@ class BaseLLM(BaseModel, ABC):
>>> llm._apply_stop_words(response)
"I need to search.\\n\\nAction: search"
"""
if not self.stop or not content:
stops = self.stop_sequences
if not stops or not content:
return content
# Find the earliest occurrence of any stop word
earliest_stop_pos = len(content)
found_stop_word = None
for stop_word in self.stop:
for stop_word in stops:
stop_pos = content.find(stop_word)
if stop_pos != -1 and stop_pos < earliest_stop_pos:
earliest_stop_pos = stop_pos

View File

@@ -679,8 +679,9 @@ class AzureCompletion(BaseLLM):
params["presence_penalty"] = self.presence_penalty
if self.max_tokens is not None:
params["max_tokens"] = self.max_tokens
if self.stop and self.supports_stop_words():
params["stop"] = self.stop
stops = self.stop_sequences
if stops and self.supports_stop_words():
params["stop"] = stops
# Handle tools/functions for Azure OpenAI models
if tools and self.is_openai_model:

View File

@@ -1328,9 +1328,11 @@ class GeminiCompletion(BaseLLM):
usage = response.usage_metadata
cached_tokens = getattr(usage, "cached_content_token_count", 0) or 0
thinking_tokens = getattr(usage, "thoughts_token_count", 0) or 0
candidates_tokens = getattr(usage, "candidates_token_count", 0) or 0
result: dict[str, Any] = {
"prompt_token_count": getattr(usage, "prompt_token_count", 0),
"candidates_token_count": getattr(usage, "candidates_token_count", 0),
"candidates_token_count": candidates_tokens,
"completion_tokens": candidates_tokens + thinking_tokens,
"total_token_count": getattr(usage, "total_token_count", 0),
"total_tokens": getattr(usage, "total_token_count", 0),
"cached_prompt_tokens": cached_tokens,

View File

@@ -0,0 +1,38 @@
"""Self-improving skills for crewAI agents.
When an Agent is configured with ``self_improve=True``, a TraceCollector
subscribes to the event bus during kickoff, captures tool calls + outcome
signals into a RunTrace, auto-grades the run, and persists the trace to
disk. Across many runs, a SkillReviewer mines those traces for recurring
approaches and emits SkillProposals for human review.
"""
from crewai.skills.self_improve.acceptance import accept_proposal, reject_proposal
from crewai.skills.self_improve.auto_grade import grade_trace
from crewai.skills.self_improve.collector import TraceCollector
from crewai.skills.self_improve.models import (
Outcome,
RunTrace,
SelfImprovementConfig,
SkillProposal,
ToolCallRecord,
)
from crewai.skills.self_improve.reviewer import SkillReviewer
from crewai.skills.self_improve.storage import ProposalStore, SkillStore, TraceStore
__all__ = [
"Outcome",
"ProposalStore",
"RunTrace",
"SelfImprovementConfig",
"SkillProposal",
"SkillReviewer",
"SkillStore",
"ToolCallRecord",
"TraceCollector",
"TraceStore",
"accept_proposal",
"grade_trace",
"reject_proposal",
]

View File

@@ -0,0 +1,104 @@
"""Materialize an accepted ``SkillProposal`` as a live ``SKILL.md`` file.
Acceptance is the human checkpoint in the self-improvement loop: until a
proposal is accepted, it lives in the proposals queue and never affects
the agent. After acceptance, the SKILL.md lands at
``~/.crewai/skills/<role-slug>/<skill-name>/SKILL.md`` where the existing
skill loader discovers it on the next kickoff.
"""
from __future__ import annotations
import json
from pathlib import Path
from crewai.skills.self_improve.models import SkillProposal
from crewai.skills.self_improve.storage import ProposalStore, SkillStore
def _format_skill_md(proposal: SkillProposal) -> str:
"""Render the proposal as a SKILL.md document.
The body is written verbatim — proposals already include their own
markdown structure (title, sections). We only prepend the YAML
frontmatter the loader requires. Both fields are JSON-quoted because
JSON strings are valid YAML scalars and handle every special-char case
safely.
"""
body = proposal.body.lstrip()
# If the LLM already emitted frontmatter (defensive), don't double it.
if body.startswith("---"):
return body if body.endswith("\n") else body + "\n"
frontmatter = (
f"---\n"
f"name: {json.dumps(proposal.name)}\n"
f"description: {json.dumps(proposal.description)}\n"
f"---\n\n"
)
return frontmatter + body if body.endswith("\n") else frontmatter + body + "\n"
def accept_proposal(
proposal: SkillProposal,
*,
force: bool = False,
skill_store: SkillStore | None = None,
proposal_store: ProposalStore | None = None,
) -> Path:
"""Write the proposal as a SKILL.md and remove it from the queue.
When ``skill_store`` is not provided, the destination is selected in
this order:
1. ``proposal.skills_dir`` — set by the reviewer from the agent's
``SelfImprovementConfig.skills_dir``. This is the common case; it
keeps accept aligned with where the agent reads from.
2. Platform default — ``<db_storage_path>/self_improve/skills/``.
Args:
proposal: The proposal to materialize.
force: When True, overwrite an existing SKILL.md at the target path.
skill_store: Explicit override; bypasses the proposal hint.
proposal_store: Override for the proposals store (test injection).
Returns:
Path to the written ``SKILL.md``.
Raises:
FileExistsError: When a SKILL.md already exists at the target and
``force=False``.
"""
if skill_store is None:
skill_store = (
SkillStore(skills_root=proposal.skills_dir)
if proposal.skills_dir is not None
else SkillStore()
)
proposal_store = proposal_store or ProposalStore()
target_dir = skill_store.skill_dir(proposal.agent_role, proposal.name)
skill_md = target_dir / "SKILL.md"
if skill_md.exists() and not force:
raise FileExistsError(
f"{skill_md} already exists. Pass force=True to overwrite."
)
target_dir.mkdir(parents=True, exist_ok=True)
skill_md.write_text(_format_skill_md(proposal), encoding="utf-8")
# Once accepted, the proposal record is no longer the source of truth —
# the SKILL.md is. Drop the queue entry so it doesn't show up in `list`.
proposal_store.delete(proposal.id, proposal.agent_role)
return skill_md
def reject_proposal(
proposal: SkillProposal,
*,
proposal_store: ProposalStore | None = None,
) -> bool:
"""Delete a proposal from the queue. Returns True if it existed."""
proposal_store = proposal_store or ProposalStore()
return proposal_store.delete(proposal.id, proposal.agent_role)

View File

@@ -0,0 +1,76 @@
"""Derive a run outcome from observable signals.
No human grading required. The reviewer treats outcomes as
confidence-weighted hints, not ground truth — clustering across runs
absorbs label noise.
"""
from __future__ import annotations
from collections import Counter
from crewai.skills.self_improve.models import Outcome, RunTrace
_RETRY_THRASH_THRESHOLD = 3
def _has_thrashing(trace: RunTrace) -> bool:
"""Detect a tool being called repeatedly with the same args summary.
A tool fired more than ``_RETRY_THRASH_THRESHOLD`` times with identical
args is likely stuck in a retry loop rather than making progress.
"""
if len(trace.tool_calls) <= _RETRY_THRASH_THRESHOLD:
return False
keys = [(t.name, t.args_summary) for t in trace.tool_calls]
most_common_count = Counter(keys).most_common(1)[0][1]
return most_common_count > _RETRY_THRASH_THRESHOLD
def _output_looks_empty(trace: RunTrace) -> bool:
if trace.output_summary is None:
return False
stripped = trace.output_summary.strip()
if not stripped:
return True
lowered = stripped.lower()
return lowered.startswith("error") or lowered.startswith("traceback")
def grade_trace(trace: RunTrace) -> Outcome:
"""Compute outcome from signals already present on the trace.
Signal hierarchy (strongest first):
1. explicit error → failure
2. guardrail decided → trust it
3. max_iter exhaustion → failure
4. tool error rate / thrashing / empty output → failure or partial
5. otherwise → success when we saw output, else unknown
"""
if trace.error:
return "failure"
if trace.guardrail_passed is True:
return "success"
if trace.guardrail_passed is False:
return "failure"
if trace.max_iter_exhausted:
return "failure"
if _has_thrashing(trace):
return "failure"
if _output_looks_empty(trace):
return "failure"
if trace.tool_call_count > 0:
error_rate = trace.tool_error_count / trace.tool_call_count
if error_rate >= 0.5:
return "failure"
if trace.output_summary:
return "success"
return "unknown"

View File

@@ -0,0 +1,298 @@
"""Trace collector that subscribes to the event bus.
One ``TraceCollector`` per Agent instance. It keeps a per-(agent, task)
in-flight trace, identifies which events belong to its agent, and
finalizes the trace on completion — auto-grading and persisting it.
"""
from __future__ import annotations
import atexit
from datetime import UTC, datetime
import logging
from pathlib import Path
import threading
from typing import TYPE_CHECKING, Any
from crewai.events.event_bus import CrewAIEventsBus, crewai_event_bus
from crewai.events.types.agent_events import (
AgentExecutionCompletedEvent,
AgentExecutionErrorEvent,
AgentExecutionStartedEvent,
LiteAgentExecutionCompletedEvent,
LiteAgentExecutionErrorEvent,
LiteAgentExecutionStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.skills.self_improve.auto_grade import grade_trace
from crewai.skills.self_improve.models import RunTrace, ToolCallRecord
from crewai.skills.self_improve.storage import TraceStore
if TYPE_CHECKING:
from crewai.agents.agent_builder.base_agent import BaseAgent
_OUTPUT_TRUNCATE = 4000
_ARGS_TRUNCATE = 200
_FLUSH_TIMEOUT = 10.0
logger = logging.getLogger(__name__)
# ``Agent.kickoff()`` can return before the bus's thread-pool handlers drain;
# without this hook a script that exits immediately would lose the just-
# finalized trace. ``flush()`` is a no-op when no events are pending, so it's
# safe to register at module import time even if no agent ever opts in.
atexit.register(lambda: crewai_event_bus.flush(timeout=_FLUSH_TIMEOUT))
def _truncate(value: Any, limit: int) -> str:
if value is None:
return ""
s = value if isinstance(value, str) else repr(value)
if len(s) <= limit:
return s
return s[: limit - 1] + ""
class TraceCollector:
"""Captures one ``RunTrace`` per agent execution by subscribing to events.
Lifecycle:
- ``attach(bus)`` registers handlers on the global event bus.
- On ``AgentExecutionStartedEvent`` for our agent, a new trace begins.
- ``ToolUsage*`` events for our agent append ``ToolCallRecord``s.
- On ``AgentExecutionCompletedEvent`` / ``AgentExecutionErrorEvent``
for our agent, the trace is auto-graded and persisted.
The collector is intentionally tolerant: a missing Started event
(e.g. because the agent was already executing when ``attach`` ran) just
skips that trace. Tool events without a current trace are ignored.
"""
def __init__(
self,
agent: BaseAgent,
store: TraceStore | None = None,
) -> None:
self._agent = agent
self._store = store or TraceStore()
self._current: RunTrace | None = None
self._tool_started_at: dict[str, datetime] = {}
self._attached = False
# Bus dispatches handlers on a thread pool, and Agent.kickoff() +
# LiteAgent each emit Started/Completed for the same logical run, so
# the lock + ``self._current is not None`` check serializes the
# "create or skip" decision and dedupes the second half of the pair.
self._lock = threading.RLock()
@property
def current_trace(self) -> RunTrace | None:
"""The in-flight trace, if an agent execution is active."""
return self._current
def _is_my_agent(self, event_agent: Any) -> bool:
if event_agent is None:
return False
if event_agent is self._agent:
return True
agent_id = getattr(event_agent, "id", None)
my_id = getattr(self._agent, "id", None)
return bool(agent_id is not None and my_id is not None and agent_id == my_id)
def _is_my_id(self, event_agent_id: str | None) -> bool:
if not event_agent_id:
return False
my_id = getattr(self._agent, "id", None)
return bool(my_id is not None and str(my_id) == str(event_agent_id))
def attach(self, bus: CrewAIEventsBus) -> None:
"""Register event handlers. Idempotent."""
if self._attached:
return
self._attached = True
@bus.on(AgentExecutionStartedEvent)
def _on_started(_source: Any, event: AgentExecutionStartedEvent) -> None:
if not self._is_my_agent(event.agent):
return
with self._lock:
if self._current is not None:
return # duplicate Started for an in-flight trace
task = event.task
self._current = RunTrace(
agent_id=str(getattr(self._agent, "id", "") or ""),
agent_role=self._agent.role,
agent_goal=getattr(self._agent, "goal", "") or "",
agent_skills_dir=self._agent_skills_dir(),
task_id=str(getattr(task, "id", "") or "") or None,
task_description=getattr(task, "description", None),
loaded_skills=self._collect_loaded_skills(),
)
@bus.on(ToolUsageStartedEvent)
def _on_tool_started(_source: Any, event: ToolUsageStartedEvent) -> None:
if not self._is_my_id(event.agent_id):
return
with self._lock:
if self._current is None:
return
self._tool_started_at[event.tool_name] = datetime.now(UTC)
@bus.on(ToolUsageFinishedEvent)
def _on_tool_finished(_source: Any, event: ToolUsageFinishedEvent) -> None:
if not self._is_my_id(event.agent_id):
return
with self._lock:
if self._current is None:
return
self._current.tool_calls.append(
ToolCallRecord(
name=event.tool_name,
args_summary=_truncate(event.tool_args, _ARGS_TRUNCATE),
ok=True,
duration_ms=self._duration_ms(
event.tool_name, event.finished_at
),
)
)
self._tool_started_at.pop(event.tool_name, None)
@bus.on(ToolUsageErrorEvent)
def _on_tool_error(_source: Any, event: ToolUsageErrorEvent) -> None:
if not self._is_my_id(event.agent_id):
return
with self._lock:
if self._current is None:
return
self._current.tool_calls.append(
ToolCallRecord(
name=event.tool_name,
args_summary=_truncate(event.tool_args, _ARGS_TRUNCATE),
ok=False,
error=_truncate(event.error, _ARGS_TRUNCATE),
duration_ms=self._duration_ms(
event.tool_name, datetime.now(UTC)
),
)
)
self._tool_started_at.pop(event.tool_name, None)
@bus.on(AgentExecutionCompletedEvent)
def _on_completed(_source: Any, event: AgentExecutionCompletedEvent) -> None:
if not self._is_my_agent(event.agent):
return
with self._lock:
if self._current is None:
return
self._current.output_summary = _truncate(event.output, _OUTPUT_TRUNCATE)
self._finalize_locked()
@bus.on(AgentExecutionErrorEvent)
def _on_error(_source: Any, event: AgentExecutionErrorEvent) -> None:
if not self._is_my_agent(event.agent):
return
with self._lock:
if self._current is None:
return
self._current.error = _truncate(event.error, _OUTPUT_TRUNCATE)
self._finalize_locked()
@bus.on(LiteAgentExecutionStartedEvent)
def _on_lite_started(
_source: Any, event: LiteAgentExecutionStartedEvent
) -> None:
if not self._is_my_id(event.agent_info.get("id")):
return
with self._lock:
if self._current is not None:
return # duplicate Started for an in-flight trace
messages = event.messages
if isinstance(messages, list):
task_desc = " ".join(
str(m.get("content", ""))
for m in messages
if isinstance(m, dict)
)
else:
task_desc = str(messages)
self._current = RunTrace(
agent_id=str(getattr(self._agent, "id", "") or ""),
agent_role=self._agent.role,
agent_goal=getattr(self._agent, "goal", "") or "",
agent_skills_dir=self._agent_skills_dir(),
task_description=_truncate(task_desc, _OUTPUT_TRUNCATE),
loaded_skills=self._collect_loaded_skills(),
)
@bus.on(LiteAgentExecutionCompletedEvent)
def _on_lite_completed(
_source: Any, event: LiteAgentExecutionCompletedEvent
) -> None:
if not self._is_my_id(event.agent_info.get("id")):
return
with self._lock:
if self._current is None:
return
self._current.output_summary = _truncate(event.output, _OUTPUT_TRUNCATE)
self._finalize_locked()
@bus.on(LiteAgentExecutionErrorEvent)
def _on_lite_error(_source: Any, event: LiteAgentExecutionErrorEvent) -> None:
if not self._is_my_id(event.agent_info.get("id")):
return
with self._lock:
if self._current is None:
return
self._current.error = _truncate(event.error, _OUTPUT_TRUNCATE)
self._finalize_locked()
def _duration_ms(self, tool_name: str, finished_at: datetime) -> int | None:
started = self._tool_started_at.get(tool_name)
if started is None:
return None
return max(0, int((finished_at - started).total_seconds() * 1000))
def _agent_skills_dir(self) -> Path | None:
"""Read skills_dir from the agent's SelfImprovementConfig if set.
We use ``getattr`` + duck typing instead of importing Agent so the
collector stays usable in tests with stub agents.
"""
config_getter = getattr(self._agent, "_self_improve_config", None)
if not callable(config_getter):
return None
try:
config = config_getter()
except Exception:
return None
return getattr(config, "skills_dir", None) if config is not None else None
def _collect_loaded_skills(self) -> list[str]:
skills = getattr(self._agent, "skills", None) or []
names: list[str] = []
for s in skills:
name = getattr(s, "name", None)
if isinstance(name, str):
names.append(name)
return names
def _finalize_locked(self) -> None:
"""Caller holds ``self._lock``."""
trace = self._current
if trace is None:
return
self._current = None
self._tool_started_at.clear()
trace.ended_at = datetime.now(UTC)
trace.outcome = grade_trace(trace)
try:
self._store.save(trace)
except OSError:
logger.exception(
"Failed to persist run trace for role %s", trace.agent_role
)

View File

@@ -0,0 +1,156 @@
"""Data models for self-improving skills."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import re
from typing import Literal
import uuid
from pydantic import BaseModel, ConfigDict, Field, field_validator
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9-]+")
_SLUG_DASHES = re.compile(r"-+")
def _slugify(value: str) -> str:
"""Force a string into the kebab-case shape the skill loader requires.
The reviewer LLM is told to emit kebab-case names, but doesn't always
comply (e.g. it emits Title Case with spaces). Without this normalization
the accepted SKILL.md fails the loader's name pattern and is silently
skipped, breaking the closed loop.
"""
s = value.strip().lower().replace("_", "-").replace(" ", "-")
s = _SLUG_NON_ALNUM.sub("", s)
s = _SLUG_DASHES.sub("-", s).strip("-")
return s[:64] or "skill"
def _now() -> datetime:
return datetime.now(UTC)
class SelfImprovementConfig(BaseModel):
"""Per-agent configuration for the self-improvement loop.
All fields are optional with sensible defaults. Pass to ``Agent`` as
``self_improve=SelfImprovementConfig(skills_dir=Path("./skills/learned"))``
to override; ``Agent(self_improve=True)`` uses defaults.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
skills_dir: Path | None = Field(
default=None,
description=(
"Where accepted SKILL.md files are written and auto-loaded from. "
"When None, falls back to <db_storage_path>/self_improve/skills/. "
"Set to a project-relative path (e.g. Path('./skills/learned')) "
"to keep accepted skills under version control."
),
)
Outcome = Literal["success", "failure", "unknown"]
ProposalKind = Literal["new", "patch_existing"]
def _new_id(prefix: str) -> str:
"""Generate a short id with a stable prefix."""
return f"{prefix}_{uuid.uuid4().hex[:12]}"
class ToolCallRecord(BaseModel):
"""One tool invocation within a run."""
model_config = ConfigDict(frozen=True)
name: str
args_summary: str = Field(
default="",
description="Truncated string repr of args, suitable for clustering.",
)
ok: bool = True
error: str | None = None
duration_ms: int | None = None
class RunTrace(BaseModel):
"""One agent + task execution.
Built incrementally by ``TraceCollector`` from event-bus events,
finalized at agent completion, then persisted to disk.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str = Field(default_factory=lambda: _new_id("run"))
agent_id: str | None = None
agent_role: str
agent_goal: str = ""
agent_skills_dir: Path | None = Field(
default=None,
description=(
"Resolved skills_dir from the agent's SelfImprovementConfig at "
"trace time. Carried into proposals so accept writes back to "
"the same place the agent reads from."
),
)
task_id: str | None = None
task_description: str | None = None
started_at: datetime = Field(default_factory=_now)
ended_at: datetime | None = None
tool_calls: list[ToolCallRecord] = Field(default_factory=list)
loaded_skills: list[str] = Field(default_factory=list)
outcome: Outcome = "unknown"
output_summary: str | None = None
error: str | None = None
max_iter_exhausted: bool = False
guardrail_passed: bool | None = None
@property
def tool_error_count(self) -> int:
return sum(1 for t in self.tool_calls if not t.ok)
@property
def tool_call_count(self) -> int:
return len(self.tool_calls)
class SkillProposal(BaseModel):
"""A proposed new or updated skill, awaiting human review.
The reviewer LLM emits these directly via a thin envelope; the server
stamps ``agent_role`` and ``derived_from_runs`` after the call, which is
why those two have permissive defaults rather than being required.
"""
model_config = ConfigDict(frozen=True)
id: str = Field(default_factory=lambda: _new_id("prop"))
agent_role: str = ""
name: str
description: str
body: str
rationale: str
confidence: float = Field(ge=0.0, le=1.0)
proposal_kind: ProposalKind = "new"
target_skill: str | None = None
derived_from_runs: list[str] = Field(default_factory=list)
skills_dir: Path | None = Field(
default=None,
description=(
"Where to write the SKILL.md when this proposal is accepted, "
"carried over from the agent's SelfImprovementConfig at trace "
"time. Falls back to platform default when None."
),
)
created_at: datetime = Field(default_factory=_now)
@field_validator("name", mode="before")
@classmethod
def _force_slug(cls, v: str | None) -> str | None:
return _slugify(v) if isinstance(v, str) else v

View File

@@ -0,0 +1,220 @@
"""LLM-driven skill reviewer.
Reads N ``RunTrace`` records, identifies recurring *approaches* (not
facts — those go to memory), and emits ``SkillProposal``s for human
review.
The reviewer never writes to the active skills directory itself; it only
populates the proposals queue. Acceptance is a separate, explicit step.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from crewai.llms.base_llm import BaseLLM
from crewai.skills.self_improve.models import RunTrace, SkillProposal
_TRACE_OUTPUT_TRUNCATE = 1500
_TRACE_TASK_TRUNCATE = 800
class _ReviewerOutput(BaseModel):
"""Envelope around the LLM's structured proposals.
``SkillProposal.agent_role`` and ``derived_from_runs`` are intentionally
server-filled post-call; the LLM doesn't need to emit them.
"""
model_config = ConfigDict(extra="ignore")
proposals: list[SkillProposal] = Field(default_factory=list)
_SYSTEM_PROMPT = """\
You are reviewing execution traces from a CrewAI agent to decide what
*reusable approaches* are worth saving as agent skills.
The agent's role: {role}
The agent's goal: {goal}
You see {n} traces from past runs. Your job:
1. Identify recurring NON-DETERMINISTIC APPROACHES — patterns of how this
agent thinks about a class of problems. Examples of approaches:
- "When asked to scaffold a project, always start by listing required
config files before writing code."
- "If a tool returns empty results, rephrase the query before retrying."
2. DROP facts. Facts go to memory, not skills. Examples of facts:
- "the user prefers dark mode"
- "the API key for service X is in vault Y"
- "this project uses pytest"
3. Drop one-off observations. A skill needs ≥2 traces showing the same
approach. If only one trace shows it, omit.
4. If a proposal would patch an EXISTING loaded skill (listed below), set
proposal_kind="patch_existing" and target_skill=<that skill name>.
Otherwise leave proposal_kind="new".
5. Score confidence honestly. Below 0.6 will be auto-rejected, so don't
pad. A pattern seen in 2/{n} traces with no contradictions ≈ 0.65; in
all {n} traces with consistent outcome ≈ 0.85.
6. SKILL.md body should be markdown:
- First line: short description in italics or as a sentence
- "When to use this skill" section
- "How to apply it" section with concrete steps
- No invented facts or fabricated tool names
Loaded skills already available to this agent:
{loaded_skills}
Proposals already QUEUED for human review (do not re-propose these — a
prior reviewer round already surfaced them, they're awaiting accept/reject):
{pending_proposals}
If a recurring pattern matches a queued proposal semantically, simply
omit it from your output. Don't restate the same approach under a new
name. The human curator will resolve the queue.
Return a JSON object matching the schema. Empty proposals list is a valid
answer when no recurring approach is worth saving.
"""
_USER_PROMPT = """\
TRACES ({n}):
{traces}
Review the traces above and emit your proposals.
"""
def _format_trace(trace: RunTrace) -> str:
"""One block per trace, compact but enough signal."""
task = (trace.task_description or "").strip()
if len(task) > _TRACE_TASK_TRUNCATE:
task = task[: _TRACE_TASK_TRUNCATE - 1] + ""
output = (trace.output_summary or "").strip()
if len(output) > _TRACE_OUTPUT_TRUNCATE:
output = output[: _TRACE_OUTPUT_TRUNCATE - 1] + ""
tool_lines: list[str] = []
for t in trace.tool_calls:
tag = "ok" if t.ok else "ERR"
tool_lines.append(f" [{tag}] {t.name}({t.args_summary})")
tools_block = "\n".join(tool_lines) if tool_lines else " (no tool calls)"
return (
f"--- {trace.id} outcome={trace.outcome} "
f"max_iter_exhausted={trace.max_iter_exhausted} "
f"guardrail_passed={trace.guardrail_passed}\n"
f"task:\n{task}\n\n"
f"tool_calls ({len(trace.tool_calls)}):\n{tools_block}\n\n"
f"output_summary:\n{output}\n"
)
class SkillReviewer:
"""Synthesize ``SkillProposal``s from a batch of ``RunTrace``s.
Stateless; the only state lives in the disk store. Pass an LLM (any
``BaseLLM`` instance — typically a small/cheap model like Haiku is
enough since the reviewer just summarizes).
"""
def __init__(
self,
*,
agent_role: str,
agent_goal: str,
llm: BaseLLM,
min_traces: int = 3,
confidence_floor: float = 0.6,
) -> None:
self.agent_role = agent_role
self.agent_goal = agent_goal
self.llm = llm
self.min_traces = min_traces
self.confidence_floor = confidence_floor
def review(
self,
traces: list[RunTrace],
*,
loaded_skill_names: list[str] | None = None,
pending_proposals: list[SkillProposal] | None = None,
) -> list[SkillProposal]:
"""Run the LLM review and return filtered proposals.
Returns an empty list when ``len(traces) < self.min_traces`` so
the reviewer is safe to call early — it just no-ops.
Pass ``pending_proposals`` (typically the current contents of the
ProposalStore for this role) so the reviewer doesn't re-emit
semantic duplicates of items already awaiting human review.
"""
if len(traces) < self.min_traces:
return []
loaded_skills_str = (
"\n".join(f" - {name}" for name in loaded_skill_names)
if loaded_skill_names
else " (none)"
)
if pending_proposals:
pending_str = "\n".join(
f" - {p.name}: {p.description}" for p in pending_proposals
)
else:
pending_str = " (none)"
system = _SYSTEM_PROMPT.format(
role=self.agent_role,
goal=self.agent_goal,
n=len(traces),
loaded_skills=loaded_skills_str,
pending_proposals=pending_str,
)
user = _USER_PROMPT.format(
n=len(traces),
traces="\n".join(_format_trace(t) for t in traces),
)
result = self.llm.call(
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
response_model=_ReviewerOutput,
)
if not isinstance(result, _ReviewerOutput):
return []
run_ids = [t.id for t in traces]
# Carry the agent's skills_dir from the latest trace forward so the
# accept step writes to the same path the agent reads from. We pick
# the *last* trace's value because configuration drift (a user
# changing skills_dir between runs) should land at the most recent.
skills_dir = next(
(t.agent_skills_dir for t in reversed(traces) if t.agent_skills_dir),
None,
)
return [
p.model_copy(
update={
"agent_role": self.agent_role,
"derived_from_runs": run_ids,
"skills_dir": skills_dir,
}
)
for p in result.proposals
if p.confidence >= self.confidence_floor
]

View File

@@ -0,0 +1,177 @@
"""On-disk storage for traces and skill proposals.
Layout::
<root>/
traces/<role>/<run_id>.json
skill_proposals/<role>/<proposal_id>.json
skills/<role>/<skill-name>/SKILL.md
The default root is ``db_storage_path() / "self_improve"`` — the same
project-scoped, platform-correct data dir that memory DBs use (set by
``appdirs.user_data_dir`` and overridable via ``CREWAI_STORAGE_DIR``).
``CREWAI_SELF_IMPROVE_DIR`` overrides specifically this feature's root,
useful for tests or migrations.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
from crewai.skills.self_improve.models import RunTrace, SkillProposal
from crewai.utilities.paths import db_storage_path
_ENV_VAR = "CREWAI_SELF_IMPROVE_DIR"
_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
def _slug(role: str) -> str:
"""Slugify an agent role for use as a directory name."""
s = role.strip().lower().replace(" ", "-")
s = _SLUG_RE.sub("", s)
return s or "agent"
def _resolve_root(root: Path | None) -> Path:
if root is not None:
return root
env = os.environ.get(_ENV_VAR)
if env:
return Path(env)
return Path(db_storage_path()) / "self_improve"
def _write_json(path: Path, payload: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(payload, encoding="utf-8")
class TraceStore:
"""Filesystem store for ``RunTrace`` records."""
def __init__(self, root: Path | None = None) -> None:
self.root = _resolve_root(root) / "traces"
def role_dir(self, role: str) -> Path:
return self.root / _slug(role)
def path_for(self, trace: RunTrace) -> Path:
return self.role_dir(trace.agent_role) / f"{trace.id}.json"
def save(self, trace: RunTrace) -> Path:
path = self.path_for(trace)
_write_json(path, trace.model_dump_json(indent=2))
return path
def list_for_role(self, role: str) -> list[Path]:
d = self.role_dir(role)
if not d.exists():
return []
return sorted(d.glob("*.json"))
def load(self, path: Path) -> RunTrace:
return RunTrace.model_validate_json(path.read_text(encoding="utf-8"))
def count_for_role(self, role: str) -> int:
return len(self.list_for_role(role))
class ProposalStore:
"""Filesystem store for ``SkillProposal`` records pending human review."""
def __init__(self, root: Path | None = None) -> None:
self.root = _resolve_root(root) / "skill_proposals"
def role_dir(self, role: str) -> Path:
return self.root / _slug(role)
def path_for(self, proposal: SkillProposal) -> Path:
return self.role_dir(proposal.agent_role) / f"{proposal.id}.json"
def save(self, proposal: SkillProposal) -> Path:
path = self.path_for(proposal)
_write_json(path, proposal.model_dump_json(indent=2))
return path
def list_for_role(self, role: str) -> list[Path]:
d = self.role_dir(role)
if not d.exists():
return []
return sorted(d.glob("*.json"))
def load(self, path: Path) -> SkillProposal:
return SkillProposal.model_validate_json(path.read_text(encoding="utf-8"))
def delete(self, proposal_id: str, role: str) -> bool:
path = self.role_dir(role) / f"{proposal_id}.json"
if path.exists():
path.unlink()
return True
return False
def find(self, proposal_id: str) -> SkillProposal | None:
"""Locate a proposal by id across all role dirs. Returns None if missing."""
if not self.root.exists():
return None
for role_dir in self.root.iterdir():
if not role_dir.is_dir():
continue
path = role_dir / f"{proposal_id}.json"
if path.exists():
return self.load(path)
return None
def list_all(self) -> list[SkillProposal]:
"""All proposals across roles, oldest first by file id."""
if not self.root.exists():
return []
out: list[SkillProposal] = []
for role_dir in sorted(self.root.iterdir()):
if not role_dir.is_dir():
continue
out.extend(self.load(path) for path in sorted(role_dir.glob("*.json")))
return out
class SkillStore:
"""Filesystem store for accepted (live) skills.
Each accepted ``SkillProposal`` becomes a directory under
``role_dir(role) / skill_name`` with a ``SKILL.md`` inside, matching
the layout the existing skill loader discovers.
Two ways to construct:
- ``SkillStore()`` — root is ``<db_storage_path>/self_improve/skills/``
(the platform default colocated with traces + proposals).
- ``SkillStore(skills_root=Path("./skills/learned"))`` — root is the
given path verbatim. Use this when the agent is configured with
``SelfImprovementConfig(skills_dir=...)`` so accepted skills land in
the project tree.
"""
def __init__(
self,
root: Path | None = None,
skills_root: Path | None = None,
) -> None:
self.root = (
skills_root if skills_root is not None else _resolve_root(root) / "skills"
)
def role_dir(self, role: str) -> Path:
return self.root / _slug(role)
def skill_dir(self, role: str, skill_name: str) -> Path:
return self.role_dir(role) / skill_name
def has_any(self, role: str) -> bool:
d = self.role_dir(role)
if not d.exists():
return False
return any(
(child / "SKILL.md").is_file() for child in d.iterdir() if child.is_dir()
)

View File

@@ -53,7 +53,11 @@ from crewai.tasks.task_output import TaskOutput
from crewai.tools.base_tool import BaseTool
from crewai.utilities.config import process_config
from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified
from crewai.utilities.converter import Converter, convert_to_model
from crewai.utilities.converter import (
Converter,
async_convert_to_model,
convert_to_model,
)
from crewai.utilities.file_store import (
clear_task_files,
get_all_files,
@@ -681,7 +685,7 @@ class Task(BaseModel):
json_output = None
elif not self._guardrails and not self._guardrail:
raw = result
pydantic_output, json_output = self._export_output(result)
pydantic_output, json_output = await self._aexport_output(result)
else:
raw = result
pydantic_output, json_output = None, None
@@ -1110,7 +1114,7 @@ Follow these guidelines:
)
def _export_output(
self, result: str
self, result: str | BaseModel
) -> tuple[BaseModel | None, dict[str, Any] | None]:
pydantic_output: BaseModel | None = None
json_output: dict[str, Any] | None = None
@@ -1123,19 +1127,44 @@ Follow these guidelines:
self.agent,
self.converter_cls,
)
if isinstance(model_output, BaseModel):
pydantic_output = model_output
elif isinstance(model_output, dict):
json_output = model_output
elif isinstance(model_output, str):
try:
json_output = json.loads(model_output)
except json.JSONDecodeError:
json_output = None
pydantic_output, json_output = self._unpack_model_output(model_output)
return pydantic_output, json_output
async def _aexport_output(
self, result: str | BaseModel
) -> tuple[BaseModel | None, dict[str, Any] | None]:
"""Async equivalent of ``_export_output`` — uses ``acall`` so the event loop is not blocked."""
pydantic_output: BaseModel | None = None
json_output: dict[str, Any] | None = None
if self.output_pydantic or self.output_json:
model_output = await async_convert_to_model(
result,
self.output_pydantic,
self.output_json,
self.agent,
self.converter_cls,
)
pydantic_output, json_output = self._unpack_model_output(model_output)
return pydantic_output, json_output
@staticmethod
def _unpack_model_output(
model_output: dict[str, Any] | BaseModel | str,
) -> tuple[BaseModel | None, dict[str, Any] | None]:
if isinstance(model_output, BaseModel):
return model_output, None
if isinstance(model_output, dict):
return None, model_output
if isinstance(model_output, str):
try:
return None, json.loads(model_output)
except json.JSONDecodeError:
return None, None
return None, None
def _get_output_format(self) -> OutputFormat:
if self.output_json:
return OutputFormat.JSON
@@ -1364,7 +1393,7 @@ Follow these guidelines:
if isinstance(guardrail_result.result, str):
task_output.raw = guardrail_result.result
pydantic_output, json_output = self._export_output(
pydantic_output, json_output = await self._aexport_output(
guardrail_result.result
)
task_output.pydantic = pydantic_output
@@ -1421,7 +1450,7 @@ Follow these guidelines:
json_output = None
else:
raw = result
pydantic_output, json_output = self._export_output(result)
pydantic_output, json_output = await self._aexport_output(result)
task_output = TaskOutput(
name=self.name or self.description,

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
from collections.abc import Callable, Iterator, Sequence
import concurrent.futures
import contextlib
import contextvars
from dataclasses import dataclass, field
from datetime import datetime
@@ -22,7 +23,7 @@ from crewai.agents.parser import (
parse,
)
from crewai.cli.config import Settings
from crewai.llms.base_llm import BaseLLM
from crewai.llms.base_llm import BaseLLM, call_stop_override
from crewai.tools import BaseTool as CrewAITool
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
@@ -238,6 +239,38 @@ def extract_task_section(text: str) -> str:
return text
def _executor_stop_words(
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
) -> list[str]:
"""Return the executor's stop words, regardless of which field name it uses."""
if executor_context is None:
return []
stops = getattr(executor_context, "stop", None)
if stops is None:
stops = getattr(executor_context, "stop_words", None)
return list(stops) if stops else []
@contextlib.contextmanager
def _llm_stop_words_applied(
llm: LLM | BaseLLM,
executor_context: CrewAgentExecutor | AgentExecutor | LiteAgent | None,
) -> Iterator[None]:
"""Apply the executor's stop words to the LLM for the duration of one call.
Uses :func:`crewai.llms.base_llm.call_stop_override` so the LLM's stop
field is never mutated. Safe under concurrent execution: the override is
propagated via a :class:`contextvars.ContextVar` and is scoped to this
call's task / thread context.
"""
extra = _executor_stop_words(executor_context)
if not extra or not isinstance(llm, BaseLLM) or set(extra).issubset(llm.stop):
yield
return
with call_stop_override(llm, list(set(llm.stop + extra))):
yield
def has_reached_max_iterations(iterations: int, max_iterations: int) -> bool:
"""Check if the maximum number of iterations has been reached.
@@ -459,18 +492,15 @@ def get_llm_response(
"""
messages = _prepare_llm_call(executor_context, messages, printer, verbose=verbose)
try:
answer = llm.call(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
except Exception as e:
raise e
answer = llm.call(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return _validate_and_finalize_llm_response(
answer, executor_context, printer, verbose=verbose
@@ -515,18 +545,15 @@ async def aget_llm_response(
"""
messages = _prepare_llm_call(executor_context, messages, printer, verbose=verbose)
try:
answer = await llm.acall(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
except Exception as e:
raise e
answer = await llm.acall(
messages,
tools=tools,
callbacks=callbacks,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_model=response_model,
)
return _validate_and_finalize_llm_response(
answer, executor_context, printer, verbose=verbose
@@ -1565,11 +1592,12 @@ def execute_single_native_tool_call(
color="green",
)
# Check result_as_answer
is_result_as_answer = bool(
original_tool
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
and not error_event_emitted
and not hook_blocked
)
return NativeToolCallResult(

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import json
import re
from typing import TYPE_CHECKING, Any, Final, TypedDict
@@ -41,6 +42,45 @@ class ConverterError(Exception):
class Converter(OutputConverter):
"""Class that converts text into either pydantic or json."""
def _build_messages(self) -> list[dict[str, str]]:
return [
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
def _coerce_response_to_pydantic(self, response: Any) -> BaseModel:
"""Validate an LLM response into the configured Pydantic model.
Pure post-processing — performs no I/O. Shared by ``to_pydantic`` and
``ato_pydantic`` so the validation/partial-JSON fallback logic stays in
a single place.
"""
if isinstance(response, BaseModel):
return response
try:
return self.model.model_validate_json(response)
except ValidationError:
partial = handle_partial_json(
result=response,
model=self.model,
is_json_output=False,
agent=None,
)
if isinstance(partial, BaseModel):
return partial
if isinstance(partial, dict):
return self.model.model_validate(partial)
if isinstance(partial, str):
try:
return self.model.model_validate_json(partial)
except Exception as parse_err:
raise ConverterError(
f"Failed to convert partial JSON result into Pydantic: {parse_err}"
) from parse_err
raise ConverterError(
"handle_partial_json returned an unexpected type."
) from None
def to_pydantic(self, current_attempt: int = 1) -> BaseModel:
"""Convert text to pydantic.
@@ -56,50 +96,12 @@ class Converter(OutputConverter):
try:
if self.llm.supports_function_calling():
response = self.llm.call(
messages=[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
],
messages=self._build_messages(),
response_model=self.model,
)
if isinstance(response, BaseModel):
result = response
else:
result = self.model.model_validate_json(response)
else:
response = self.llm.call(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
try:
# Try to directly validate the response JSON
result = self.model.model_validate_json(response)
except ValidationError:
# If direct validation fails, attempt to extract valid JSON
result = handle_partial_json( # type: ignore[assignment]
result=response,
model=self.model,
is_json_output=False,
agent=None,
)
# Ensure result is a BaseModel instance
if not isinstance(result, BaseModel):
if isinstance(result, dict):
result = self.model.model_validate(result)
elif isinstance(result, str):
try:
result = self.model.model_validate_json(result)
except Exception as parse_err:
raise ConverterError(
f"Failed to convert partial JSON result into Pydantic: {parse_err}"
) from parse_err
else:
raise ConverterError(
"handle_partial_json returned an unexpected type."
) from None
return result
response = self.llm.call(self._build_messages())
return self._coerce_response_to_pydantic(response)
except ValidationError as e:
if current_attempt < self.max_attempts:
return self.to_pydantic(current_attempt + 1)
@@ -113,6 +115,30 @@ class Converter(OutputConverter):
f"Failed to convert text into a Pydantic model due to error: {e}"
) from e
async def ato_pydantic(self, current_attempt: int = 1) -> BaseModel:
"""Async equivalent of ``to_pydantic`` — uses ``acall`` so the event loop is not blocked."""
try:
if self.llm.supports_function_calling():
response = await self.llm.acall(
messages=self._build_messages(),
response_model=self.model,
)
else:
response = await self.llm.acall(self._build_messages())
return self._coerce_response_to_pydantic(response)
except ValidationError as e:
if current_attempt < self.max_attempts:
return await self.ato_pydantic(current_attempt + 1)
raise ConverterError(
f"Failed to convert text into a Pydantic model due to validation error: {e}"
) from e
except Exception as e:
if current_attempt < self.max_attempts:
return await self.ato_pydantic(current_attempt + 1)
raise ConverterError(
f"Failed to convert text into a Pydantic model due to error: {e}"
) from e
def to_json(self, current_attempt: int = 1) -> str | ConverterError | Any: # type: ignore[override]
"""Convert text to json.
@@ -129,19 +155,28 @@ class Converter(OutputConverter):
try:
if self.llm.supports_function_calling():
return self._create_instructor().to_json()
return json.dumps(
self.llm.call(
[
{"role": "system", "content": self.instructions},
{"role": "user", "content": self.text},
]
)
)
return json.dumps(self.llm.call(self._build_messages()))
except Exception as e:
if current_attempt < self.max_attempts:
return self.to_json(current_attempt + 1)
return ConverterError(f"Failed to convert text into JSON, error: {e}.")
async def ato_json(self, current_attempt: int = 1) -> str | ConverterError | Any:
"""Async equivalent of ``to_json``.
The function-calling path delegates to ``InternalInstructor`` (currently
sync-only); we run it via ``asyncio.to_thread`` so the event loop stays
free.
"""
try:
if self.llm.supports_function_calling():
return await asyncio.to_thread(self._create_instructor().to_json)
return json.dumps(await self.llm.acall(self._build_messages()))
except Exception as e:
if current_attempt < self.max_attempts:
return await self.ato_json(current_attempt + 1)
return ConverterError(f"Failed to convert text into JSON, error: {e}.")
def _create_instructor(self) -> InternalInstructor[Any]:
"""Create an instructor."""
@@ -153,16 +188,18 @@ class Converter(OutputConverter):
def convert_to_model(
result: str,
result: str | BaseModel,
output_pydantic: type[BaseModel] | None,
output_json: type[BaseModel] | None,
agent: Agent | BaseAgent | None = None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Convert a result string to a Pydantic model or JSON.
"""Convert a result to a Pydantic model or JSON.
Args:
result: The result string to convert.
result: The result to convert. Usually a JSON string, but a Pydantic
instance is also accepted when an upstream caller already produced
a structured object.
output_pydantic: The Pydantic model class to convert to.
output_json: The Pydantic model class to convert to JSON.
agent: The agent instance.
@@ -175,6 +212,11 @@ def convert_to_model(
if model is None:
return result
if isinstance(result, BaseModel):
if isinstance(result, model):
return result.model_dump() if output_json else result
result = result.model_dump_json()
if converter_cls:
return convert_with_instructions(
result=result,
@@ -347,6 +389,144 @@ def convert_with_instructions(
return exported_result
async def async_convert_to_model(
result: str | BaseModel,
output_pydantic: type[BaseModel] | None,
output_json: type[BaseModel] | None,
agent: Agent | BaseAgent | None = None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async equivalent of ``convert_to_model`` — uses native ``acall``.
Mirrors the dispatch semantics of the sync version exactly; the only
difference is that LLM-bearing branches are awaited.
"""
model = output_pydantic or output_json
if model is None:
return result
if isinstance(result, BaseModel):
if isinstance(result, model):
return result.model_dump() if output_json else result
result = result.model_dump_json()
if converter_cls:
return await async_convert_with_instructions(
result=result,
model=model,
is_json_output=bool(output_json),
agent=agent,
converter_cls=converter_cls,
)
try:
escaped_result = json.dumps(json.loads(result, strict=False))
return validate_model(
result=escaped_result, model=model, is_json_output=bool(output_json)
)
except (json.JSONDecodeError, ValidationError):
return await async_handle_partial_json(
result=result,
model=model,
is_json_output=bool(output_json),
agent=agent,
converter_cls=converter_cls,
)
except Exception as e:
if agent and getattr(agent, "verbose", True):
PRINTER.print(
content=f"Unexpected error during model conversion: {type(e).__name__}: {e}. Returning original result.",
color="red",
)
return result
async def async_handle_partial_json(
result: str,
model: type[BaseModel],
is_json_output: bool,
agent: Agent | BaseAgent | None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async equivalent of ``handle_partial_json`` — defers LLM fallback to ``acall``."""
match = _JSON_PATTERN.search(result)
if match:
try:
parsed = json.loads(match.group(), strict=False)
except json.JSONDecodeError:
return await async_convert_with_instructions(
result=result,
model=model,
is_json_output=is_json_output,
agent=agent,
converter_cls=converter_cls,
)
try:
exported_result = model.model_validate(parsed)
if is_json_output:
return exported_result.model_dump()
return exported_result
except ValidationError:
raise
except Exception as e:
if agent and getattr(agent, "verbose", True):
PRINTER.print(
content=f"Unexpected error during partial JSON handling: {type(e).__name__}: {e}. Attempting alternative conversion method.",
color="red",
)
return await async_convert_with_instructions(
result=result,
model=model,
is_json_output=is_json_output,
agent=agent,
converter_cls=converter_cls,
)
async def async_convert_with_instructions(
result: str,
model: type[BaseModel],
is_json_output: bool,
agent: Agent | BaseAgent | None,
converter_cls: type[Converter] | None = None,
) -> dict[str, Any] | BaseModel | str:
"""Async equivalent of ``convert_with_instructions`` — calls ``ato_pydantic``/``ato_json``."""
if agent is None:
raise TypeError("Agent must be provided if converter_cls is not specified.")
llm = getattr(agent, "function_calling_llm", None) or agent.llm
if llm is None:
raise ValueError("Agent must have a valid LLM instance for conversion")
instructions = get_conversion_instructions(model=model, llm=llm)
converter = create_converter(
agent=agent,
converter_cls=converter_cls,
llm=llm,
text=result,
model=model,
instructions=instructions,
)
exported_result = (
await converter.ato_pydantic()
if not is_json_output
else await converter.ato_json()
)
if isinstance(exported_result, ConverterError):
if agent and getattr(agent, "verbose", True):
PRINTER.print(
content=f"Failed to convert result to model: {exported_result}",
color="red",
)
return result
return exported_result
def get_conversion_instructions(
model: type[BaseModel], llm: BaseLLM | LLM | str | Any
) -> str:

View File

@@ -2452,3 +2452,167 @@ def test_agent_mcps_accepts_legacy_prefix_with_tool():
mcps=["crewai-amp:notion#get_page"],
)
assert agent.mcps == ["crewai-amp:notion#get_page"]
class TestSharedLLMStopWords:
"""Regression tests for shared LLM stop words mutation (issue #5141).
Stop words from one executor must not leak into the shared LLM permanently
or pollute other agents sharing that LLM.
"""
@staticmethod
def _make_executor(llm: LLM, stop_words: list[str]) -> CrewAgentExecutor:
"""Build a CrewAgentExecutor with minimal deps."""
from crewai.agents.tools_handler import ToolsHandler
agent = Agent(role="r", goal="g", backstory="b")
task = Task(description="d", expected_output="o", agent=agent)
return CrewAgentExecutor(
agent=agent,
task=task,
llm=llm,
crew=None,
prompt={"prompt": "p {input} {tool_names} {tools}"},
max_iter=5,
tools=[],
tools_names="",
stop_words=stop_words,
tools_description="",
tools_handler=ToolsHandler(),
)
def test_executor_init_does_not_mutate_shared_llm(self) -> None:
"""Constructing executors must not touch the shared LLM's stop list."""
shared = LLM(model="gpt-4", stop=["Original:"])
original = list(shared.stop)
a = self._make_executor(shared, stop_words=["StopA:"])
b = self._make_executor(shared, stop_words=["StopB:"])
assert shared.stop == original
assert a.llm is shared
assert b.llm is shared
def test_effective_stop_reflects_override_inside_context(self) -> None:
"""Inside the helper, the effective stop list includes the executor's words."""
from crewai.utilities.agent_utils import _llm_stop_words_applied
shared = LLM(model="gpt-4", stop=["Original:"])
executor = self._make_executor(shared, stop_words=["Observation:"])
with _llm_stop_words_applied(shared, executor):
assert set(shared.stop_sequences) == {"Original:", "Observation:"}
assert shared.stop == ["Original:"]
assert shared.stop == ["Original:"]
assert shared.stop_sequences == ["Original:"]
def test_override_cleared_when_context_raises(self) -> None:
"""A failed call must still clear the per-call stop override."""
from crewai.utilities.agent_utils import _llm_stop_words_applied
shared = LLM(model="gpt-4", stop=["Original:"])
executor = self._make_executor(shared, stop_words=["Observation:"])
try:
with _llm_stop_words_applied(shared, executor):
raise RuntimeError("boom")
except RuntimeError:
pass
assert shared.stop == ["Original:"]
assert shared.stop_sequences == ["Original:"]
def test_override_applies_for_post_processing_when_api_lacks_stop_support(
self,
) -> None:
"""Models that lack API-level stop support still need the override.
Native providers (e.g. Azure on gpt-5/o-series) read ``stop_sequences``
in ``_apply_stop_words`` to truncate the response post-hoc even when
``supports_stop_words()`` returns False, so the override must be set
regardless of API-level support. (Issue raised by Cursor Bugbot.)
"""
from unittest.mock import patch
from crewai.utilities.agent_utils import _llm_stop_words_applied
shared = LLM(model="gpt-4", stop=["Original:"])
executor = self._make_executor(shared, stop_words=["Observation:"])
with patch.object(shared, "supports_stop_words", return_value=False):
with _llm_stop_words_applied(shared, executor):
assert set(shared.stop_sequences) == {"Original:", "Observation:"}
assert shared.stop == ["Original:"]
assert shared.stop_sequences == ["Original:"]
def test_concurrent_overrides_do_not_collide(self) -> None:
"""Concurrent agents on a shared LLM must each see their own effective stop."""
import asyncio
from crewai.utilities.agent_utils import _llm_stop_words_applied
shared = LLM(model="gpt-4", stop=["Original:"])
exec_a = self._make_executor(shared, stop_words=["StopA:"])
exec_b = self._make_executor(shared, stop_words=["StopB:"])
async def run(executor: CrewAgentExecutor, expected: str) -> set[str]:
with _llm_stop_words_applied(shared, executor):
await asyncio.sleep(0)
seen = set(shared.stop_sequences)
assert expected in seen
return seen
async def main() -> tuple[set[str], set[str]]:
return await asyncio.gather(
run(exec_a, "StopA:"), run(exec_b, "StopB:")
)
a_seen, b_seen = asyncio.run(main())
assert a_seen == {"Original:", "StopA:"}
assert b_seen == {"Original:", "StopB:"}
assert shared.stop == ["Original:"]
assert shared.stop_sequences == ["Original:"]
def test_override_does_not_leak_to_other_llm_instances(self) -> None:
"""Override for one LLM must not affect another LLM (e.g. function_calling_llm).
Regression for Cursor Bugbot: a global ContextVar would leak the
override to every BaseLLM that reads stop_sequences during the scope.
"""
from crewai.utilities.agent_utils import _llm_stop_words_applied
target = LLM(model="gpt-4", stop=["TargetStop:"])
other = LLM(model="gpt-4", stop=["OtherStop:"])
executor = self._make_executor(target, stop_words=["Observation:"])
with _llm_stop_words_applied(target, executor):
assert set(target.stop_sequences) == {"TargetStop:", "Observation:"}
assert other.stop_sequences == ["OtherStop:"]
assert target.stop_sequences == ["TargetStop:"]
assert other.stop_sequences == ["OtherStop:"]
def test_override_propagates_to_nested_direct_llm_calls(self) -> None:
"""Once invoke wraps with the override, nested direct llm.call sites
(StepExecutor, handle_max_iterations_exceeded) see the merged stops.
Regression for Cursor Bugbot: those direct call sites bypass
get_llm_response, so the override must be set at executor entry, not
only around get_llm_response.
"""
from crewai.utilities.agent_utils import _llm_stop_words_applied
shared = LLM(model="gpt-4", stop=["Original:"])
executor = self._make_executor(shared, stop_words=["Observation:"])
seen: list[set[str]] = []
def nested_direct_call() -> None:
seen.append(set(shared.stop_sequences))
with _llm_stop_words_applied(shared, executor):
nested_direct_call()
assert seen == [{"Original:", "Observation:"}]
assert shared.stop == ["Original:"]

View File

@@ -596,6 +596,35 @@ def test_gemini_token_usage_tracking():
assert usage.total_tokens > 0
def test_gemini_thoughts_tokens_counted_in_completion_and_total():
"""Gemini's thoughts_token_count must be folded into completion_tokens so the
tracked total matches the API's total_token_count for thinking models."""
from crewai.llms.providers.gemini.completion import GeminiCompletion
llm = GeminiCompletion(model="gemini-2.0-flash-001")
response = MagicMock()
response.usage_metadata = MagicMock(
prompt_token_count=100,
candidates_token_count=50,
thoughts_token_count=25,
total_token_count=175,
cached_content_token_count=0,
)
usage = llm._extract_token_usage(response)
assert usage["candidates_token_count"] == 50
assert usage["completion_tokens"] == 75
assert usage["reasoning_tokens"] == 25
llm._track_token_usage_internal(usage)
summary = llm.get_token_usage_summary()
assert summary.prompt_tokens == 100
assert summary.completion_tokens == 75
assert summary.total_tokens == 175
assert summary.reasoning_tokens == 25
@pytest.mark.vcr()
def test_gemini_tool_returning_float():
"""

View File

@@ -0,0 +1,243 @@
"""Tests for self_improve/acceptance.py."""
from __future__ import annotations
from pathlib import Path
import pytest
from crewai.skills.self_improve.acceptance import (
_format_skill_md,
accept_proposal,
reject_proposal,
)
from crewai.skills.self_improve.models import SkillProposal
from crewai.skills.self_improve.storage import ProposalStore, SkillStore
@pytest.fixture
def proposal() -> SkillProposal:
return SkillProposal(
agent_role="Senior Researcher",
name="cite-sources",
description="Always cite sources in research outputs",
body="# Cite Sources\n\n*Always cite sources.*\n\n## When to use\n\nWhenever you write a research summary.\n",
rationale="Seen in 3 of 4 traces",
confidence=0.8,
derived_from_runs=["run_a", "run_b", "run_c"],
)
@pytest.fixture
def stores(tmp_path: Path):
return SkillStore(root=tmp_path), ProposalStore(root=tmp_path)
class TestFormatSkillMd:
def test_includes_yaml_frontmatter(self, proposal: SkillProposal) -> None:
out = _format_skill_md(proposal)
assert out.startswith("---\n")
# Values are JSON-quoted (valid YAML scalars).
assert 'name: "cite-sources"' in out
assert '"Always cite sources in research outputs"' in out
assert "# Cite Sources" in out
def test_quotes_descriptions_with_special_chars(self) -> None:
prop = SkillProposal(
agent_role="r",
name="n",
description='Has a "quote" and: colon',
body="b",
rationale="r",
confidence=0.7,
)
out = _format_skill_md(prop)
# JSON-quoted: backslash-escapes the inner quote, retains the colon literally.
assert 'description: "Has a \\"quote\\" and: colon"' in out
def test_passes_through_body_with_existing_frontmatter(self) -> None:
prop = SkillProposal(
agent_role="r",
name="n",
description="d",
body="---\nname: n\n---\n# Body\n",
rationale="r",
confidence=0.7,
)
out = _format_skill_md(prop)
# No double frontmatter
assert out.count("---") == 2 # one open, one close
class TestAcceptProposal:
def test_writes_skill_md_at_expected_path(
self, stores, proposal: SkillProposal
) -> None:
skill_store, proposal_store = stores
proposal_store.save(proposal)
path = accept_proposal(
proposal,
skill_store=skill_store,
proposal_store=proposal_store,
)
assert path.name == "SKILL.md"
# role gets slugified
assert "senior-researcher" in str(path)
assert "cite-sources" in str(path)
body = path.read_text()
assert body.startswith("---\n")
assert "# Cite Sources" in body
def test_removes_proposal_from_queue_on_accept(
self, stores, proposal: SkillProposal
) -> None:
skill_store, proposal_store = stores
proposal_store.save(proposal)
assert proposal_store.find(proposal.id) is not None
accept_proposal(
proposal, skill_store=skill_store, proposal_store=proposal_store
)
assert proposal_store.find(proposal.id) is None
def test_refuses_to_overwrite_existing(
self, stores, proposal: SkillProposal
) -> None:
skill_store, proposal_store = stores
proposal_store.save(proposal)
accept_proposal(
proposal, skill_store=skill_store, proposal_store=proposal_store
)
# Re-save and re-accept the same proposal id (or a fresh one) → conflict
proposal_store.save(proposal)
with pytest.raises(FileExistsError):
accept_proposal(
proposal,
skill_store=skill_store,
proposal_store=proposal_store,
)
def test_force_overwrites(self, stores, proposal: SkillProposal) -> None:
skill_store, proposal_store = stores
proposal_store.save(proposal)
accept_proposal(
proposal, skill_store=skill_store, proposal_store=proposal_store
)
proposal_store.save(proposal)
path = accept_proposal(
proposal,
skill_store=skill_store,
proposal_store=proposal_store,
force=True,
)
assert path.exists()
class TestRejectProposal:
def test_removes_from_queue(self, stores, proposal: SkillProposal) -> None:
_, proposal_store = stores
proposal_store.save(proposal)
assert reject_proposal(proposal, proposal_store=proposal_store) is True
assert proposal_store.find(proposal.id) is None
def test_returns_false_when_missing(
self, stores, proposal: SkillProposal
) -> None:
_, proposal_store = stores
assert reject_proposal(proposal, proposal_store=proposal_store) is False
class TestSkillsDirPlumbing:
"""The agent's ``SelfImprovementConfig.skills_dir`` should flow through
trace → proposal → accept so the SKILL.md lands at the same place the
agent reads from, regardless of which CLI/TUI path triggered the accept.
"""
def test_proposal_skills_dir_overrides_default(self, tmp_path: Path) -> None:
# Simulate the reviewer setting skills_dir on a proposal (carried
# over from the trace, which captured it from the agent config).
project_skills = tmp_path / "project" / "skills" / "learned"
proposal = SkillProposal(
agent_role="researcher",
name="cite-sources",
description="Always cite sources",
body="# Cite Sources\n",
rationale="r",
confidence=0.8,
skills_dir=project_skills,
)
# No skill_store passed — accept should honor proposal.skills_dir.
proposal_store = ProposalStore(root=tmp_path / "queue")
proposal_store.save(proposal)
path = accept_proposal(proposal, proposal_store=proposal_store)
# SKILL.md is at <project_skills>/<role>/<name>/SKILL.md, NOT at
# the default platform path.
assert project_skills in path.parents
assert path.name == "SKILL.md"
assert "researcher" in str(path)
assert "cite-sources" in str(path)
def test_explicit_skill_store_overrides_proposal_hint(
self, tmp_path: Path
) -> None:
# If a caller passes skill_store explicitly (e.g. CLI --skills-dir
# flag), it wins over the proposal's stored hint.
proposal_skills = tmp_path / "from-proposal"
cli_skills = tmp_path / "from-cli"
proposal = SkillProposal(
agent_role="researcher",
name="cite-sources",
description="d",
body="b",
rationale="r",
confidence=0.8,
skills_dir=proposal_skills,
)
proposal_store = ProposalStore(root=tmp_path / "queue")
proposal_store.save(proposal)
skill_store = SkillStore(skills_root=cli_skills)
path = accept_proposal(
proposal, proposal_store=proposal_store, skill_store=skill_store
)
assert cli_skills in path.parents
assert proposal_skills not in path.parents
def test_proposal_without_skills_dir_uses_platform_default(
self, tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setenv("CREWAI_SELF_IMPROVE_DIR", str(tmp_path / "platform"))
proposal = SkillProposal(
agent_role="researcher",
name="cite-sources",
description="d",
body="b",
rationale="r",
confidence=0.8,
)
proposal_store = ProposalStore(root=tmp_path / "queue")
proposal_store.save(proposal)
path = accept_proposal(proposal, proposal_store=proposal_store)
assert tmp_path / "platform" in path.parents
class TestSkillStore:
def test_has_any_detects_at_least_one_skill(
self, stores, proposal: SkillProposal
) -> None:
skill_store, proposal_store = stores
assert skill_store.has_any("Senior Researcher") is False
proposal_store.save(proposal)
accept_proposal(
proposal, skill_store=skill_store, proposal_store=proposal_store
)
assert skill_store.has_any("Senior Researcher") is True

View File

@@ -0,0 +1,85 @@
"""Tests for self_improve/auto_grade.py."""
from __future__ import annotations
from crewai.skills.self_improve.auto_grade import grade_trace
from crewai.skills.self_improve.models import RunTrace, ToolCallRecord
def _trace(**kw):
return RunTrace(agent_role="r", **kw)
def test_explicit_error_is_failure() -> None:
assert grade_trace(_trace(error="kaboom", output_summary="ok")) == "failure"
def test_guardrail_pass_overrides_other_signals() -> None:
trace = _trace(
guardrail_passed=True,
max_iter_exhausted=True, # would normally fail, but guardrail wins
output_summary="ok",
)
assert grade_trace(trace) == "success"
def test_guardrail_fail_is_failure() -> None:
assert grade_trace(_trace(guardrail_passed=False, output_summary="x")) == "failure"
def test_max_iter_is_failure() -> None:
assert grade_trace(_trace(max_iter_exhausted=True, output_summary="x")) == "failure"
def test_thrashing_is_failure() -> None:
trace = _trace(
tool_calls=[
ToolCallRecord(name="search", args_summary="q=x") for _ in range(5)
],
output_summary="ok",
)
assert grade_trace(trace) == "failure"
def test_empty_output_is_failure() -> None:
assert grade_trace(_trace(output_summary=" ")) == "failure"
def test_error_string_output_is_failure() -> None:
assert grade_trace(_trace(output_summary="Error: boom")) == "failure"
def test_minority_tool_errors_still_count_as_success() -> None:
trace = _trace(
tool_calls=[
ToolCallRecord(name="a", ok=True),
ToolCallRecord(name="b", ok=True),
ToolCallRecord(name="c", ok=False, error="x"),
],
output_summary="answer",
)
assert grade_trace(trace) == "success"
def test_failure_when_majority_tool_errors() -> None:
trace = _trace(
tool_calls=[
ToolCallRecord(name="a", ok=False, error="x"),
ToolCallRecord(name="b", ok=False, error="x"),
ToolCallRecord(name="c", ok=True),
],
output_summary="answer",
)
assert grade_trace(trace) == "failure"
def test_clean_run_is_success() -> None:
trace = _trace(
tool_calls=[ToolCallRecord(name="a", ok=True)],
output_summary="answer",
)
assert grade_trace(trace) == "success"
def test_no_signal_is_unknown() -> None:
assert grade_trace(_trace()) == "unknown"

View File

@@ -0,0 +1,103 @@
"""Tests for ``crewai skills proposals`` CLI commands."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
import pytest
from crewai.cli.skills_proposals import skills as skills_group
from crewai.skills.self_improve.models import SkillProposal
from crewai.skills.self_improve.storage import ProposalStore, SkillStore
@pytest.fixture
def proposal() -> SkillProposal:
return SkillProposal(
agent_role="researcher",
name="cite-sources",
description="Always cite sources",
body="# Cite Sources\n\nbody.",
rationale="seen in 3 traces",
confidence=0.75,
)
@pytest.fixture
def runner_with_root(tmp_path: Path):
"""CliRunner with the self-improve root patched at the storage layer."""
runner = CliRunner()
proposal_store = ProposalStore(root=tmp_path)
skill_store = SkillStore(root=tmp_path)
with patch(
"crewai.cli.skills_proposals.ProposalStore", return_value=proposal_store
), patch(
"crewai.skills.self_improve.acceptance.ProposalStore",
return_value=proposal_store,
), patch(
"crewai.skills.self_improve.acceptance.SkillStore", return_value=skill_store
):
yield runner, proposal_store, skill_store
class TestList:
def test_empty(self, runner_with_root) -> None:
runner, _, _ = runner_with_root
result = runner.invoke(skills_group, ["proposals", "list"])
assert result.exit_code == 0
assert "no pending proposals" in result.output
def test_one_pending(self, runner_with_root, proposal: SkillProposal) -> None:
runner, ps, _ = runner_with_root
ps.save(proposal)
result = runner.invoke(skills_group, ["proposals", "list"])
assert result.exit_code == 0
assert proposal.id in result.output
assert "cite-sources" in result.output
class TestShow:
def test_unknown_id_exits_nonzero(self, runner_with_root) -> None:
runner, _, _ = runner_with_root
result = runner.invoke(skills_group, ["proposals", "show", "prop_does_not_exist"])
assert result.exit_code == 1
assert "No proposal" in result.output
def test_prints_body(self, runner_with_root, proposal: SkillProposal) -> None:
runner, ps, _ = runner_with_root
ps.save(proposal)
result = runner.invoke(skills_group, ["proposals", "show", proposal.id])
assert result.exit_code == 0
assert "# Cite Sources" in result.output
assert "rationale" in result.output
class TestAccept:
def test_writes_skill_md_and_clears_queue(
self, runner_with_root, proposal: SkillProposal
) -> None:
runner, ps, ss = runner_with_root
ps.save(proposal)
result = runner.invoke(skills_group, ["proposals", "accept", proposal.id])
assert result.exit_code == 0, result.output
assert ps.find(proposal.id) is None
assert (ss.skill_dir("researcher", "cite-sources") / "SKILL.md").is_file()
def test_unknown_id_exits_nonzero(self, runner_with_root) -> None:
runner, _, _ = runner_with_root
result = runner.invoke(skills_group, ["proposals", "accept", "prop_nope"])
assert result.exit_code == 1
class TestReject:
def test_removes_from_queue(self, runner_with_root, proposal: SkillProposal) -> None:
runner, ps, _ = runner_with_root
ps.save(proposal)
result = runner.invoke(skills_group, ["proposals", "reject", proposal.id])
assert result.exit_code == 0
assert ps.find(proposal.id) is None

View File

@@ -0,0 +1,153 @@
"""Tests for self_improve/collector.py.
The collector is exercised through the real event bus inside a
``scoped_handlers()`` block. Events are constructed with
``model_construct`` so we don't need to spin up a real Agent + LLM.
"""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.agent_events import (
AgentExecutionCompletedEvent,
AgentExecutionErrorEvent,
AgentExecutionStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.skills.self_improve.collector import TraceCollector
from crewai.skills.self_improve.storage import TraceStore
def _fake_agent(*, agent_id: str = "agent-1", role: str = "researcher"):
return SimpleNamespace(id=agent_id, role=role, skills=None)
def _fake_task(task_id: str = "task-1", description: str = "do the thing"):
return SimpleNamespace(id=task_id, description=description)
def _started(agent, task) -> AgentExecutionStartedEvent:
return AgentExecutionStartedEvent.model_construct(
agent=agent, task=task, tools=[], task_prompt=task.description
)
def _completed(agent, task, output: str) -> AgentExecutionCompletedEvent:
return AgentExecutionCompletedEvent.model_construct(
agent=agent, task=task, output=output
)
def _error(agent, task, msg: str) -> AgentExecutionErrorEvent:
return AgentExecutionErrorEvent.model_construct(agent=agent, task=task, error=msg)
def _tool_started(agent_id: str, name: str, args="q=x") -> ToolUsageStartedEvent:
return ToolUsageStartedEvent.model_construct(
agent_id=agent_id, tool_name=name, tool_args=args
)
def _tool_finished(agent_id: str, name: str, args="q=x") -> ToolUsageFinishedEvent:
now = datetime.now(UTC)
return ToolUsageFinishedEvent.model_construct(
agent_id=agent_id,
tool_name=name,
tool_args=args,
started_at=now,
finished_at=now,
output="result",
)
def _tool_error(agent_id: str, name: str, args="q=x") -> ToolUsageErrorEvent:
return ToolUsageErrorEvent.model_construct(
agent_id=agent_id, tool_name=name, tool_args=args, error="boom"
)
@pytest.fixture
def store(tmp_path: Path) -> TraceStore:
return TraceStore(root=tmp_path)
def test_collects_full_run_and_persists(store: TraceStore) -> None:
agent = _fake_agent()
task = _fake_task()
collector = TraceCollector(agent, store=store)
with crewai_event_bus.scoped_handlers():
collector.attach(crewai_event_bus)
# Flush between emits so the bus thread pool can't reorder
# tool events past the completion event in this fast test path.
crewai_event_bus.emit(None, _started(agent, task))
crewai_event_bus.flush(timeout=5.0)
crewai_event_bus.emit(None, _tool_started(agent.id, "search"))
crewai_event_bus.emit(None, _tool_finished(agent.id, "search"))
crewai_event_bus.flush(timeout=5.0)
crewai_event_bus.emit(None, _completed(agent, task, "final answer"))
crewai_event_bus.flush(timeout=5.0)
saved = store.list_for_role("researcher")
assert len(saved) == 1
trace = store.load(saved[0])
assert trace.agent_role == "researcher"
assert trace.task_id == "task-1"
assert trace.output_summary == "final answer"
assert trace.outcome == "success"
assert trace.tool_call_count == 1
assert trace.tool_error_count == 0
def test_error_path_is_graded_as_failure(store: TraceStore) -> None:
agent = _fake_agent()
task = _fake_task()
collector = TraceCollector(agent, store=store)
with crewai_event_bus.scoped_handlers():
collector.attach(crewai_event_bus)
crewai_event_bus.emit(None, _started(agent, task))
crewai_event_bus.flush(timeout=5.0)
crewai_event_bus.emit(None, _tool_error(agent.id, "search"))
crewai_event_bus.flush(timeout=5.0)
crewai_event_bus.emit(None, _error(agent, task, "agent crashed"))
crewai_event_bus.flush(timeout=5.0)
[path] = store.list_for_role("researcher")
trace = store.load(path)
assert trace.outcome == "failure"
assert trace.error == "agent crashed"
assert trace.tool_error_count == 1
def test_ignores_events_for_other_agents(store: TraceStore) -> None:
mine = _fake_agent(agent_id="mine", role="researcher")
other = _fake_agent(agent_id="other", role="editor")
task = _fake_task()
collector = TraceCollector(mine, store=store)
with crewai_event_bus.scoped_handlers():
collector.attach(crewai_event_bus)
crewai_event_bus.emit(None, _started(mine, task))
crewai_event_bus.flush(timeout=5.0)
# tool events for some other agent must not pollute our trace
crewai_event_bus.emit(None, _tool_finished(other.id, "leaked-tool"))
crewai_event_bus.emit(None, _tool_finished(mine.id, "real-tool"))
crewai_event_bus.flush(timeout=5.0)
crewai_event_bus.emit(None, _completed(mine, task, "ok"))
crewai_event_bus.flush(timeout=5.0)
[path] = store.list_for_role("researcher")
trace = store.load(path)
assert [t.name for t in trace.tool_calls] == ["real-tool"]

View File

@@ -0,0 +1,106 @@
"""Tests for self_improve/models.py."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pathlib import Path
from crewai.skills.self_improve.models import (
RunTrace,
SelfImprovementConfig,
SkillProposal,
ToolCallRecord,
)
class TestSelfImprovementConfig:
def test_defaults(self) -> None:
cfg = SelfImprovementConfig()
assert cfg.skills_dir is None
def test_skills_dir_round_trip(self, tmp_path: Path) -> None:
cfg = SelfImprovementConfig(skills_dir=tmp_path / "learned")
assert cfg.skills_dir == tmp_path / "learned"
class TestRunTrace:
def test_minimal_trace_has_defaults(self) -> None:
trace = RunTrace(agent_role="researcher")
assert trace.agent_role == "researcher"
assert trace.outcome == "unknown"
assert trace.tool_calls == []
assert trace.id.startswith("run_")
assert trace.tool_call_count == 0
assert trace.tool_error_count == 0
def test_tool_counters(self) -> None:
trace = RunTrace(
agent_role="researcher",
tool_calls=[
ToolCallRecord(name="search", ok=True),
ToolCallRecord(name="search", ok=False, error="boom"),
],
)
assert trace.tool_call_count == 2
assert trace.tool_error_count == 1
def test_serializes_roundtrip(self) -> None:
trace = RunTrace(
agent_role="researcher",
tool_calls=[ToolCallRecord(name="search", args_summary="q=hi")],
)
payload = trace.model_dump_json()
roundtrip = RunTrace.model_validate_json(payload)
assert roundtrip.id == trace.id
assert roundtrip.tool_calls[0].name == "search"
class TestSkillProposal:
def test_minimal_proposal(self) -> None:
prop = SkillProposal(
agent_role="researcher",
name="my-skill",
description="A skill",
body="# body",
rationale="seen 3 times",
confidence=0.8,
)
assert prop.proposal_kind == "new"
assert prop.id.startswith("prop_")
def test_name_is_slugified(self) -> None:
# The reviewer LLM sometimes emits Title Case with spaces; we force
# kebab-case so the existing skill loader's name validator passes.
prop = SkillProposal(
agent_role="r",
name="Provide Direct Answer with Supporting Historical Context",
description="d",
body="b",
rationale="r",
confidence=0.7,
)
assert prop.name == "provide-direct-answer-with-supporting-historical-context"
def test_name_strips_punctuation_and_underscores(self) -> None:
prop = SkillProposal(
agent_role="r",
name="My Skill: V2 (the_good_one!)",
description="d",
body="b",
rationale="r",
confidence=0.7,
)
assert prop.name == "my-skill-v2-the-good-one"
def test_confidence_must_be_in_range(self) -> None:
with pytest.raises(ValidationError):
SkillProposal(
agent_role="r",
name="n",
description="d",
body="b",
rationale="r",
confidence=1.5,
)

View File

@@ -0,0 +1,208 @@
"""Tests for self_improve/reviewer.py."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from crewai.skills.self_improve.models import RunTrace, SkillProposal, ToolCallRecord
from crewai.skills.self_improve.reviewer import (
SkillReviewer,
_ReviewerOutput,
_format_trace,
)
def _llm_proposal(**kw):
"""Build a SkillProposal as the LLM would emit it (no server-filled fields)."""
base = dict(
name="x",
description="d",
body="b",
rationale="r",
confidence=0.7,
)
base.update(kw)
return SkillProposal(**base)
def _trace(**kw: Any) -> RunTrace:
base = {"agent_role": "researcher", "outcome": "success"}
base.update(kw)
return RunTrace(**base)
def _stub_llm(output: _ReviewerOutput) -> MagicMock:
"""Return a BaseLLM stand-in whose ``call`` returns the given output."""
llm = MagicMock()
llm.call = MagicMock(return_value=output)
return llm
@pytest.fixture
def reviewer_factory():
def make(llm, **kw):
return SkillReviewer(
agent_role="researcher",
agent_goal="answer questions",
llm=llm,
**kw,
)
return make
class TestFormatTrace:
def test_includes_outcome_task_and_tool_calls(self) -> None:
trace = _trace(
task_description="Find papers on X",
output_summary="Found 3 papers.",
tool_calls=[
ToolCallRecord(name="search", args_summary="q=X", ok=True),
ToolCallRecord(name="fetch", args_summary="id=42", ok=False, error="404"),
],
)
block = _format_trace(trace)
assert "outcome=success" in block
assert "Find papers on X" in block
assert "Found 3 papers." in block
assert "[ok] search(q=X)" in block
assert "[ERR] fetch(id=42)" in block
def test_truncates_long_output(self) -> None:
trace = _trace(output_summary="x" * 5000)
block = _format_trace(trace)
assert "" in block
assert len(block) < 5000
class TestSkillReviewer:
def test_returns_empty_when_below_min_traces(self, reviewer_factory) -> None:
llm = _stub_llm(_ReviewerOutput(proposals=[]))
reviewer = reviewer_factory(llm, min_traces=3)
result = reviewer.review([_trace(), _trace()])
assert result == []
llm.call.assert_not_called()
def test_filters_by_confidence_floor(self, reviewer_factory) -> None:
llm_output = _ReviewerOutput(
proposals=[
_llm_proposal(name="keep", confidence=0.8),
_llm_proposal(name="drop", confidence=0.3),
]
)
llm = _stub_llm(llm_output)
reviewer = reviewer_factory(llm, min_traces=2, confidence_floor=0.6)
out = reviewer.review([_trace(), _trace(), _trace()])
assert [p.name for p in out] == ["keep"]
def test_sets_agent_role_and_run_ids(self, reviewer_factory) -> None:
llm = _stub_llm(
_ReviewerOutput(proposals=[_llm_proposal(name="cite-sources")])
)
reviewer = reviewer_factory(llm, min_traces=2)
traces = [_trace(), _trace(), _trace()]
out = reviewer.review(traces)
assert len(out) == 1
prop = out[0]
assert prop.agent_role == "researcher"
assert prop.derived_from_runs == [t.id for t in traces]
assert prop.proposal_kind == "new"
def test_passes_loaded_skills_into_prompt(self, reviewer_factory) -> None:
llm = _stub_llm(_ReviewerOutput(proposals=[]))
reviewer = reviewer_factory(llm, min_traces=2)
reviewer.review(
[_trace(), _trace(), _trace()],
loaded_skill_names=["citing", "search-tactics"],
)
call_kwargs = llm.call.call_args.kwargs
messages = call_kwargs["messages"]
system_msg = next(m["content"] for m in messages if m["role"] == "system")
assert "citing" in system_msg
assert "search-tactics" in system_msg
def test_handles_non_model_response_gracefully(self, reviewer_factory) -> None:
# An LLM that returned a plain string instead of the structured model.
llm = MagicMock()
llm.call = MagicMock(return_value="totally not a model")
reviewer = reviewer_factory(llm, min_traces=2)
out = reviewer.review([_trace(), _trace(), _trace()])
assert out == []
def test_skills_dir_propagates_from_trace_to_proposal(
self, reviewer_factory, tmp_path
) -> None:
from pathlib import Path
skills_dir = Path(tmp_path) / "project" / "skills" / "learned"
llm = _stub_llm(_ReviewerOutput(proposals=[_llm_proposal(name="cite")]))
reviewer = reviewer_factory(llm, min_traces=2)
traces = [
_trace(agent_skills_dir=skills_dir),
_trace(agent_skills_dir=skills_dir),
_trace(agent_skills_dir=skills_dir),
]
[prop] = reviewer.review(traces)
assert prop.skills_dir == skills_dir
def test_pending_proposals_appear_in_prompt(self, reviewer_factory) -> None:
llm = _stub_llm(_ReviewerOutput(proposals=[]))
reviewer = reviewer_factory(llm, min_traces=2)
pending = [
SkillProposal(
agent_role="researcher",
name="cite-sources",
description="Always cite sources in research output.",
body="b",
rationale="r",
confidence=0.8,
)
]
reviewer.review(
[_trace(), _trace(), _trace()],
pending_proposals=pending,
)
messages = llm.call.call_args.kwargs["messages"]
system_msg = next(m["content"] for m in messages if m["role"] == "system")
assert "cite-sources" in system_msg
assert "Always cite sources" in system_msg
# And it should be in the queued-proposals section, not the loaded
# skills section.
assert "QUEUED" in system_msg
def test_pending_proposals_default_none_renders_none(
self, reviewer_factory
) -> None:
llm = _stub_llm(_ReviewerOutput(proposals=[]))
reviewer = reviewer_factory(llm, min_traces=2)
reviewer.review([_trace(), _trace(), _trace()])
system_msg = next(
m["content"]
for m in llm.call.call_args.kwargs["messages"]
if m["role"] == "system"
)
# The "(none)" sentinel renders under both sections when nothing was passed.
assert system_msg.count("(none)") >= 2
def test_patch_existing_kind_passes_through(self, reviewer_factory) -> None:
llm = _stub_llm(
_ReviewerOutput(
proposals=[
_llm_proposal(
name="citing-v2",
confidence=0.9,
proposal_kind="patch_existing",
target_skill="citing",
)
]
)
)
reviewer = reviewer_factory(llm, min_traces=2)
[prop] = reviewer.review(
[_trace(), _trace(), _trace()], loaded_skill_names=["citing"]
)
assert prop.proposal_kind == "patch_existing"
assert prop.target_skill == "citing"

View File

@@ -0,0 +1,69 @@
"""Tests for self_improve/storage.py."""
from __future__ import annotations
from pathlib import Path
import pytest
from crewai.skills.self_improve.models import RunTrace, SkillProposal
from crewai.skills.self_improve.storage import ProposalStore, TraceStore
@pytest.fixture
def trace() -> RunTrace:
return RunTrace(agent_role="Senior Researcher", output_summary="hello")
@pytest.fixture
def proposal() -> SkillProposal:
return SkillProposal(
agent_role="Senior Researcher",
name="cite-sources",
description="Always cite sources",
body="# body",
rationale="3 of 5 runs cited",
confidence=0.7,
)
class TestTraceStore:
def test_save_and_load_roundtrip(self, tmp_path: Path, trace: RunTrace) -> None:
store = TraceStore(root=tmp_path)
path = store.save(trace)
assert path.exists()
# role gets slugified into the dir name
assert "senior-researcher" in str(path)
loaded = store.load(path)
assert loaded.id == trace.id
assert loaded.output_summary == "hello"
def test_list_for_role(self, tmp_path: Path) -> None:
store = TraceStore(root=tmp_path)
for _ in range(3):
store.save(RunTrace(agent_role="researcher"))
assert store.count_for_role("researcher") == 3
def test_role_slug_is_filesystem_safe(self, tmp_path: Path) -> None:
store = TraceStore(root=tmp_path)
store.save(RunTrace(agent_role="Weird/Role:Name!"))
# only safe chars survive after slugify
assert any(p.is_dir() for p in store.root.iterdir())
class TestProposalStore:
def test_save_and_load_roundtrip(
self, tmp_path: Path, proposal: SkillProposal
) -> None:
store = ProposalStore(root=tmp_path)
path = store.save(proposal)
loaded = store.load(path)
assert loaded.id == proposal.id
assert loaded.name == "cite-sources"
def test_delete(self, tmp_path: Path, proposal: SkillProposal) -> None:
store = ProposalStore(root=tmp_path)
store.save(proposal)
assert store.delete(proposal.id, "Senior Researcher") is True
assert store.delete(proposal.id, "Senior Researcher") is False

View File

@@ -1,12 +1,14 @@
"""Tests for async task execution."""
import pytest
from pydantic import BaseModel
from unittest.mock import AsyncMock, MagicMock, patch
from crewai.agent import Agent
from crewai.task import Task
from crewai.tasks.task_output import TaskOutput
from crewai.tasks.output_format import OutputFormat
from crewai.utilities.converter import Converter
@pytest.fixture
@@ -383,4 +385,73 @@ class TestAsyncTaskOutput:
assert result.description == "Test description"
assert result.expected_output == "Test expected"
assert result.raw == "Test result"
assert result.agent == "Test Agent"
assert result.agent == "Test Agent"
class _AsyncOnlyOutput(BaseModel):
value: str
class TestAsyncOutputConversion:
"""Regression tests for native-async output conversion (issue #5230).
Ensures `_aexport_output` reaches the LLM via `acall` and never via the
blocking `call` method.
"""
@pytest.mark.asyncio
async def test_aexport_output_uses_acall_not_call(self) -> None:
mock_llm = MagicMock()
mock_llm.supports_function_calling.return_value = False
mock_llm.acall = AsyncMock(return_value='{"value": "ok"}')
mock_llm.call = MagicMock(
side_effect=AssertionError("call() must NOT be invoked from async path")
)
converter = Converter(
llm=mock_llm,
model=_AsyncOnlyOutput,
text="raw",
instructions="convert",
max_attempts=1,
)
result = await converter.ato_pydantic()
assert isinstance(result, _AsyncOnlyOutput)
assert result.value == "ok"
mock_llm.acall.assert_awaited_once()
mock_llm.call.assert_not_called()
@pytest.mark.asyncio
async def test_ato_json_function_calling_does_not_block_event_loop(self) -> None:
"""The function-calling JSON path must run via asyncio.to_thread.
``InternalInstructor`` is sync-only; `ato_json` should offload it so the
event loop is not blocked.
"""
mock_llm = MagicMock()
mock_llm.supports_function_calling.return_value = True
converter = Converter(
llm=mock_llm,
model=_AsyncOnlyOutput,
text="raw",
instructions="convert",
max_attempts=1,
)
sentinel = '{"value": "ok"}'
with patch.object(
converter, "_create_instructor"
) as mock_create, patch(
"crewai.utilities.converter.asyncio.to_thread", new_callable=AsyncMock
) as mock_to_thread:
instructor = MagicMock()
instructor.to_json = MagicMock(return_value=sentinel)
mock_create.return_value = instructor
mock_to_thread.return_value = sentinel
result = await converter.ato_json()
assert result == sentinel
mock_to_thread.assert_awaited_once_with(instructor.to_json)

View File

@@ -1254,6 +1254,119 @@ async def test_async_task_execution_call_count(researcher, writer):
assert mock_execute_sync.call_count == 1
def test_mixed_sync_async_task_outputs_not_dropped(researcher, writer):
"""Sync outputs accumulated before a pending async batch must survive the flush."""
sync1_output = TaskOutput(description="sync1", raw="s1", agent="researcher")
async1_output = TaskOutput(description="async1", raw="a1", agent="researcher")
sync2_output = TaskOutput(description="sync2", raw="s2", agent="writer")
sync1 = Task(description="sync1", expected_output="x", agent=researcher)
async1 = Task(
description="async1",
expected_output="x",
agent=researcher,
async_execution=True,
)
sync2 = Task(description="sync2", expected_output="x", agent=writer)
sync1.output = sync1_output
async1.output = async1_output
sync2.output = sync2_output
crew = Crew(agents=[researcher, writer], tasks=[sync1, async1, sync2])
mock_future = MagicMock(spec=Future)
mock_future.result.return_value = async1_output
with (
patch.object(
Task, "execute_sync", side_effect=[sync1_output, sync2_output]
),
patch.object(Task, "execute_async", return_value=mock_future),
):
result = crew.kickoff()
assert [o.raw for o in result.tasks_output] == ["s1", "a1", "s2"]
@pytest.mark.asyncio
async def test_mixed_sync_async_task_outputs_not_dropped_native_async(
researcher, writer
):
"""Same regression as the sync path, exercised via akickoff (native async)."""
sync1_output = TaskOutput(description="sync1", raw="s1", agent="researcher")
async1_output = TaskOutput(description="async1", raw="a1", agent="researcher")
sync2_output = TaskOutput(description="sync2", raw="s2", agent="writer")
sync1 = Task(description="sync1", expected_output="x", agent=researcher)
async1 = Task(
description="async1",
expected_output="x",
agent=researcher,
async_execution=True,
)
sync2 = Task(description="sync2", expected_output="x", agent=writer)
sync1.output = sync1_output
async1.output = async1_output
sync2.output = sync2_output
crew = Crew(agents=[researcher, writer], tasks=[sync1, async1, sync2])
aexecute_outputs = iter([sync1_output, async1_output, sync2_output])
async def fake_aexecute_sync(*_args: Any, **_kwargs: Any) -> TaskOutput:
return next(aexecute_outputs)
with patch.object(Task, "aexecute_sync", side_effect=fake_aexecute_sync):
result = await crew.akickoff()
assert [o.raw for o in result.tasks_output] == ["s1", "a1", "s2"]
def test_pending_async_outputs_preserved_through_conditional_task(researcher, writer):
"""A conditional task encountered after a pending async batch must not silently drop the async output."""
sync1_output = TaskOutput(description="sync1", raw="s1", agent="researcher")
async1_output = TaskOutput(description="async1", raw="a1", agent="researcher")
def always_skip(_: TaskOutput) -> bool:
return False
sync1 = Task(description="sync1", expected_output="x", agent=researcher)
async1 = Task(
description="async1",
expected_output="x",
agent=researcher,
async_execution=True,
)
conditional = ConditionalTask(
description="conditional",
expected_output="x",
agent=writer,
condition=always_skip,
)
sync1.output = sync1_output
async1.output = async1_output
crew = Crew(
agents=[researcher, writer], tasks=[sync1, async1, conditional]
)
mock_future = MagicMock(spec=Future)
mock_future.result.return_value = async1_output
with (
patch.object(Task, "execute_sync", return_value=sync1_output),
patch.object(Task, "execute_async", return_value=mock_future),
):
result = crew.kickoff()
raws = [o.raw for o in result.tasks_output]
assert raws[:2] == ["s1", "a1"]
assert len(result.tasks_output) == 3
@pytest.mark.vcr()
def test_kickoff_for_each_single_input():
"""Tests if kickoff_for_each works with a single input."""
@@ -4519,8 +4632,8 @@ def test_sets_flow_context_when_using_crewbase_pattern_inside_flow():
flow.kickoff()
assert captured_crew is not None
assert captured_crew._flow_id == flow.execution_id # type: ignore[attr-defined]
assert captured_crew._request_id == flow.execution_id # type: ignore[attr-defined]
assert captured_crew._flow_id == flow.flow_id # type: ignore[attr-defined]
assert captured_crew._request_id == flow.flow_id # type: ignore[attr-defined]
def test_sets_flow_context_when_outside_flow(researcher, writer):
@@ -4554,8 +4667,8 @@ def test_sets_flow_context_when_inside_flow(researcher, writer):
flow = MyFlow()
result = flow.kickoff()
assert result._flow_id == flow.execution_id # type: ignore[attr-defined]
assert result._request_id == flow.execution_id # type: ignore[attr-defined]
assert result._flow_id == flow.flow_id # type: ignore[attr-defined]
assert result._request_id == flow.flow_id # type: ignore[attr-defined]
def test_reset_knowledge_with_no_crew_knowledge(researcher, writer):

View File

@@ -1,127 +0,0 @@
"""Regression tests for ``Flow.execution_id``.
``execution_id`` is the stable tracking identifier for a single flow run.
It must stay independent of ``state.id`` so that consumers passing an
``id`` in ``inputs`` (used for persistence restore) cannot destabilize
the identity used by telemetry, tracing, and external correlation.
"""
from __future__ import annotations
from typing import Any
import pytest
from crewai.flow.flow import Flow, FlowState, start
from crewai.flow.flow_context import current_flow_id, current_flow_request_id
class _CaptureState(FlowState):
captured_flow_id: str = ""
captured_state_id: str = ""
captured_current_flow_id: str = ""
captured_execution_id: str = ""
class _IdentityCaptureFlow(Flow[_CaptureState]):
initial_state = _CaptureState
@start()
def capture(self) -> None:
self.state.captured_flow_id = self.flow_id
self.state.captured_state_id = self.state.id
self.state.captured_current_flow_id = current_flow_id.get() or ""
self.state.captured_execution_id = self.execution_id
def test_execution_id_defaults_to_fresh_uuid_per_instance() -> None:
a = _IdentityCaptureFlow()
b = _IdentityCaptureFlow()
assert a.execution_id
assert b.execution_id
assert a.execution_id != b.execution_id
def test_execution_id_survives_consumer_id_in_inputs() -> None:
flow = _IdentityCaptureFlow()
original_execution_id = flow.execution_id
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state.id == "consumer-supplied-id"
assert flow.flow_id == "consumer-supplied-id"
assert flow.execution_id == original_execution_id
assert flow.execution_id != "consumer-supplied-id"
def test_two_runs_with_same_consumer_id_have_distinct_execution_ids() -> None:
flow_a = _IdentityCaptureFlow()
flow_b = _IdentityCaptureFlow()
colliding_id = "shared-consumer-id"
flow_a.kickoff(inputs={"id": colliding_id})
flow_b.kickoff(inputs={"id": colliding_id})
assert flow_a.state.id == colliding_id
assert flow_b.state.id == colliding_id
assert flow_a.execution_id != flow_b.execution_id
def test_execution_id_is_writable() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
assert flow.execution_id == "external-task-id"
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.execution_id == "external-task-id"
assert flow.state.id == "consumer-supplied-id"
def test_current_flow_id_context_var_matches_execution_id() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state.captured_current_flow_id == "external-task-id"
assert flow.state.captured_flow_id == "consumer-supplied-id"
assert flow.state.captured_execution_id == "external-task-id"
def test_execution_id_not_included_in_serialized_state() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
flow.kickoff()
dumped = flow.state.model_dump()
assert "execution_id" not in dumped
assert "_execution_id" not in dumped
assert dumped["id"] == flow.state.id
def test_dict_state_flow_also_exposes_stable_execution_id() -> None:
class DictFlow(Flow[dict[str, Any]]):
initial_state = dict # type: ignore[assignment]
@start()
def noop(self) -> None:
pass
flow = DictFlow()
original = flow.execution_id
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state["id"] == "consumer-supplied-id"
assert flow.execution_id == original
@pytest.fixture(autouse=True)
def _reset_flow_context_vars():
yield
for var in (current_flow_id, current_flow_request_id):
try:
var.set(None)
except LookupError:
# ContextVar was never set in this context; nothing to reset.
pass

View File

@@ -3,6 +3,7 @@
import os
from typing import Dict, List
import pytest
from crewai.flow.flow import Flow, FlowState, listen, start
from crewai.flow.persistence import persist
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
@@ -248,3 +249,242 @@ def test_persistence_with_base_model(tmp_path):
assert message.type == "text"
assert message.content == "Hello, World!"
assert isinstance(flow.state._unwrap(), State)
def test_fork_with_restore_from_state_id(tmp_path):
"""Fork: restore_from_state_id hydrates state from source flow_uuid; new run gets a
fresh state.id; source's history is preserved (the fork's @persist writes go under
the new state.id, not the source's)."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class ForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
# Run 1: build up source state. counter goes 0 -> 1.
flow1 = ForkableFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
# Resume on the same uuid bumps counter to 2 in the SAME flow_uuid history.
flow1b = ForkableFlow(persistence=persistence)
flow1b.kickoff(inputs={"id": source_uuid})
assert flow1b.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 2
# Fork: hydrate from source, but persist under a fresh state.id.
flow2 = ForkableFlow(persistence=persistence)
flow2.kickoff(restore_from_state_id=source_uuid)
# Fork has a different state.id from the source.
assert flow2.state.id != source_uuid
# Hydrated from source's latest snapshot (counter=2), then incremented to 3.
assert flow2.state.counter == 3
# Source's history is unchanged after the fork.
assert persistence.load_state(source_uuid)["counter"] == 2
# Fork's writes landed under its own state.id.
assert persistence.load_state(flow2.state.id)["counter"] == 3
def test_fork_with_pinned_state_id(tmp_path):
"""Fork into a pinned state.id (inputs.id supplied alongside restore_from_state_id):
the new run uses inputs.id as state.id and hydrates from restore_from_state_id."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class PinnableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = PinnableFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
pinned_uuid = "pinned-fork-uuid-1234"
flow2 = PinnableFlow(persistence=persistence)
flow2.kickoff(
inputs={"id": pinned_uuid},
restore_from_state_id=source_uuid,
)
# state.id pinned to inputs.id, NOT the source uuid.
assert flow2.state.id == pinned_uuid
# Hydrated from source: counter started at 1, step incremented to 2.
assert flow2.state.counter == 2
# Source's history is unchanged.
assert persistence.load_state(source_uuid)["counter"] == 1
# Fork's writes are under the pinned uuid.
assert persistence.load_state(pinned_uuid)["counter"] == 2
def test_restore_from_state_id_not_found_silent_fallback(tmp_path):
"""Lookup miss on restore_from_state_id silently falls through to default behavior."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class FallbackFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = FallbackFlow(persistence=persistence)
# No source UUID exists — should not raise.
flow.kickoff(restore_from_state_id="no-such-uuid")
# Default state path: counter starts at 0 and step increments to 1.
assert flow.state.counter == 1
# state.id is the auto-generated one, NOT the missing source.
assert flow.state.id != "no-such-uuid"
def test_restore_from_state_id_none_is_no_op(tmp_path):
"""restore_from_state_id=None (default) preserves baseline kickoff behavior."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class BaselineFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = BaselineFlow(persistence=persistence)
flow.kickoff(restore_from_state_id=None)
assert flow.state.counter == 1
def test_fork_conflict_with_from_checkpoint_raises():
"""Passing both from_checkpoint and restore_from_state_id raises ValueError, naming
both parameters."""
from crewai.state import CheckpointConfig
class ConflictFlow(Flow[TestState]):
@start()
def step(self):
pass
flow = ConflictFlow()
with pytest.raises(ValueError) as excinfo:
flow.kickoff(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="some-uuid",
)
msg = str(excinfo.value)
assert "from_checkpoint" in msg
assert "restore_from_state_id" in msg
@pytest.mark.asyncio
async def test_fork_via_kickoff_async(tmp_path):
"""kickoff_async honors restore_from_state_id: hydrates from source, mints fresh
state.id, persists under the new id, source history preserved."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AsyncForkableFlow(persistence=persistence)
await flow1.kickoff_async()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
flow2 = AsyncForkableFlow(persistence=persistence)
await flow2.kickoff_async(restore_from_state_id=source_uuid)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(flow2.state.id)["counter"] == 2
@pytest.mark.asyncio
async def test_fork_via_akickoff(tmp_path):
"""akickoff is the public async alias and must accept restore_from_state_id with
the same semantics as kickoff_async."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AkickoffForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AkickoffForkableFlow(persistence=persistence)
await flow1.akickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
flow2 = AkickoffForkableFlow(persistence=persistence)
await flow2.akickoff(restore_from_state_id=source_uuid)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(flow2.state.id)["counter"] == 2
@pytest.mark.asyncio
async def test_akickoff_pinned_fork(tmp_path):
"""akickoff with both inputs.id and restore_from_state_id pins state.id while
hydrating from the source."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class PinnableAsyncFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = PinnableAsyncFlow(persistence=persistence)
await flow1.akickoff()
source_uuid = flow1.state.id
pinned_uuid = "pinned-akickoff-fork-uuid"
flow2 = PinnableAsyncFlow(persistence=persistence)
await flow2.akickoff(
inputs={"id": pinned_uuid},
restore_from_state_id=source_uuid,
)
assert flow2.state.id == pinned_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(pinned_uuid)["counter"] == 2
@pytest.mark.asyncio
async def test_akickoff_fork_conflict_with_from_checkpoint_raises():
"""akickoff must raise the same conflict ValueError as kickoff/kickoff_async when
both from_checkpoint and restore_from_state_id are set."""
from crewai.state import CheckpointConfig
class AsyncConflictFlow(Flow[TestState]):
@start()
def step(self):
pass
flow = AsyncConflictFlow()
with pytest.raises(ValueError) as excinfo:
await flow.akickoff(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="some-uuid",
)
msg = str(excinfo.value)
assert "from_checkpoint" in msg
assert "restore_from_state_id" in msg

View File

@@ -690,6 +690,27 @@ def test_multiple_guardrails_with_pydantic_output():
assert parsed["processed"] is True
def test_export_output_accepts_pydantic_input():
"""Regression test for #5458: _export_output must not crash with TypeError
when called with a Pydantic instance (e.g. when an upstream caller passes
an already-converted model from a context task)."""
from pydantic import BaseModel
class StructuredResult(BaseModel):
value: str
task = create_smart_task(
description="Test pydantic export",
expected_output="Structured output",
output_pydantic=StructuredResult,
)
instance = StructuredResult(value="ok")
pydantic_output, json_output = task._export_output(instance)
assert pydantic_output is instance
assert json_output is None
def test_guardrails_vs_single_guardrail_mutual_exclusion():
"""Test that guardrails list nullifies single guardrail."""

View File

@@ -17,6 +17,8 @@ from crewai.utilities.agent_utils import (
_format_messages_for_summary,
_split_messages_into_chunks,
convert_tools_to_openai_schema,
execute_single_native_tool_call,
NativeToolCallResult,
parse_tool_call_args,
summarize_messages,
)
@@ -1033,3 +1035,91 @@ class TestParseToolCallArgs:
_, error = parse_tool_call_args("{bad json}", "tool", "call_7")
assert error is not None
assert set(error.keys()) == {"call_id", "func_name", "result", "from_cache", "original_tool"}
class TestExecuteSingleNativeToolCall:
"""Tests for execute_single_native_tool_call."""
def test_result_as_answer_false_on_tool_error(self) -> None:
"""When a tool with result_as_answer=True raises, result_as_answer must be False.
Regression test for https://github.com/crewAIInc/crewAI/issues/5156
"""
from unittest.mock import MagicMock
class FailingTool(BaseTool):
name: str = "failing_tool"
description: str = "A tool that always fails"
result_as_answer: bool = True
def _run(self, **kwargs: Any) -> str:
raise RuntimeError("intentional failure")
tool = FailingTool()
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "failing_tool"
tool_call.function.arguments = "{}"
result = execute_single_native_tool_call(
tool_call,
available_functions={"failing_tool": tool._run},
original_tools=[tool],
structured_tools=None,
tools_handler=None,
agent=None,
task=None,
crew=None,
event_source=MagicMock(),
printer=None,
verbose=False,
)
assert isinstance(result, NativeToolCallResult)
assert result.result_as_answer is False
assert "Error executing tool" in result.result
def test_result_as_answer_false_when_hook_blocks(self) -> None:
"""When a before-hook blocks a tool with result_as_answer=True, result_as_answer must be False."""
from unittest.mock import MagicMock
from crewai.hooks.tool_hooks import (
clear_before_tool_call_hooks,
register_before_tool_call_hook,
)
class BlockedTool(BaseTool):
name: str = "blocked_tool"
description: str = "A tool whose execution will be blocked by a hook"
result_as_answer: bool = True
def _run(self, **kwargs: Any) -> str:
return "should not run"
tool = BlockedTool()
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "blocked_tool"
tool_call.function.arguments = "{}"
register_before_tool_call_hook(lambda _ctx: False)
try:
result = execute_single_native_tool_call(
tool_call,
available_functions={"blocked_tool": tool._run},
original_tools=[tool],
structured_tools=None,
tools_handler=None,
agent=None,
task=None,
crew=None,
event_source=MagicMock(),
printer=None,
verbose=False,
)
finally:
clear_before_tool_call_hooks()
assert isinstance(result, NativeToolCallResult)
assert result.result_as_answer is False
assert "blocked by hook" in result.result

View File

@@ -87,6 +87,31 @@ def test_convert_to_model_with_no_model() -> None:
assert output == "Plain text"
def test_convert_to_model_with_basemodel_input_matching_pydantic() -> None:
instance = SimpleModel(name="John", age=30)
output = convert_to_model(instance, SimpleModel, None, None)
assert output is instance
def test_convert_to_model_with_basemodel_input_matching_json() -> None:
instance = SimpleModel(name="John", age=30)
output = convert_to_model(instance, None, SimpleModel, None)
assert output == {"name": "John", "age": 30}
def test_convert_to_model_with_basemodel_input_different_class() -> None:
class OtherModel(BaseModel):
name: str
age: int
extra: str = "default"
instance = OtherModel(name="John", age=30, extra="ignored")
output = convert_to_model(instance, SimpleModel, None, None)
assert isinstance(output, SimpleModel)
assert output.name == "John"
assert output.age == 30
def test_convert_to_model_with_special_characters() -> None:
json_string_test = """
{

View File

@@ -11,6 +11,8 @@ Installed automatically via the workspace (`uv sync`). Requires:
- `ENTERPRISE_REPO` env var — GitHub repo for enterprise releases
- `ENTERPRISE_VERSION_DIRS` env var — comma-separated directories to bump in the enterprise repo
- `ENTERPRISE_CREWAI_DEP_PATH` env var — path to the pyproject.toml with the `crewai[tools]` pin in the enterprise repo
- `ENTERPRISE_WORKFLOW_PATHS` env var — comma-separated workflow file paths in the enterprise repo whose `crewai[extras]==<version>` pins should be rewritten on each release (e.g. `.github/workflows/tests.yml`)
- `ENTERPRISE_EXTRA_PACKAGES` env var — comma-separated packages to also pin in enterprise pyproject files, in addition to `crewai` / `crewai[extras]`
## Commands

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.4"
__version__ = "1.14.5a2"

View File

@@ -1207,7 +1207,12 @@ _ENTERPRISE_WORKFLOW_PATHS: Final[tuple[str, ...]] = tuple(
def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool:
"""Update the crewai[tools] pin in an enterprise pyproject.toml.
"""Update crewai pins in an enterprise pyproject.toml.
Pins ``crewai`` / ``crewai[extras]`` via ``_pin_crewai_deps`` and
additionally pins any dashed ``crewai-*`` packages configured via
``ENTERPRISE_EXTRA_PACKAGES`` (e.g. ``crewai-enterprise``), which
``_pin_crewai_deps`` does not cover.
Args:
pyproject_path: Path to the pyproject.toml file.
@@ -1219,20 +1224,57 @@ def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool:
if not pyproject_path.exists():
return False
changed = False
content = pyproject_path.read_text()
new_content = _pin_crewai_deps(content, version)
if new_content != content:
pyproject_path.write_text(new_content)
return True
return False
changed = True
if update_pyproject_dependencies(
pyproject_path, version, extra_packages=list(_ENTERPRISE_EXTRA_PACKAGES)
):
changed = True
return changed
def _update_workflow_crewai_pins(workflow_path: Path, version: str) -> bool:
"""Rewrite ``crewai[extras]==<old>`` pins in a single workflow file.
Operates line-by-line on the raw file via ``_repin_crewai_install``
so only version numbers change and all formatting is preserved.
Args:
workflow_path: Path to a workflow YAML file.
version: New crewai version string.
Returns:
True if the file was modified.
"""
if not workflow_path.exists():
return False
raw = workflow_path.read_text()
lines = raw.splitlines(keepends=True)
changed = False
for i, line in enumerate(lines):
if "crewai[" not in line:
continue
new_line = _repin_crewai_install(line, version)
if new_line != line:
lines[i] = new_line
changed = True
if not changed:
return False
workflow_path.write_text("".join(lines))
return True
def _update_enterprise_workflows(repo_dir: Path, version: str) -> list[Path]:
"""Update crewai version pins in enterprise CI workflow files.
Applies ``_repin_crewai_install`` line-by-line on the raw file so
only version numbers change and all formatting is preserved.
Args:
repo_dir: Root of the cloned enterprise repo.
version: New crewai version string.
@@ -1243,29 +1285,31 @@ def _update_enterprise_workflows(repo_dir: Path, version: str) -> list[Path]:
updated: list[Path] = []
for rel_path in _ENTERPRISE_WORKFLOW_PATHS:
workflow = repo_dir / rel_path
if not workflow.exists():
continue
raw = workflow.read_text()
lines = raw.splitlines(keepends=True)
changed = False
for i, line in enumerate(lines):
if "crewai[" not in line:
continue
new_line = _repin_crewai_install(line, version)
if new_line != line:
lines[i] = new_line
changed = True
if changed:
new_raw = "".join(lines)
else:
new_raw = raw
if new_raw != raw:
workflow.write_text(new_raw)
if _update_workflow_crewai_pins(workflow, version):
updated.append(workflow)
return updated
def _update_repo_workflows_crewai_pins(repo_dir: Path, version: str) -> list[Path]:
"""Update crewai pins across all GitHub workflow files in a repo.
Args:
repo_dir: Root of the cloned repo.
version: New crewai version string.
Returns:
List of workflow paths that were modified.
"""
workflows_dir = repo_dir / ".github" / "workflows"
if not workflows_dir.exists():
return []
updated: list[Path] = []
for workflow in sorted(workflows_dir.iterdir()):
if workflow.suffix not in (".yml", ".yaml"):
continue
if _update_workflow_crewai_pins(workflow, version):
updated.append(workflow)
return updated
@@ -1314,9 +1358,10 @@ _PYPI_POLL_TIMEOUT: Final[int] = 600
def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
"""Update the deployment test repo to pin the new crewai version.
Clones the repo, updates the crewai[tools] pin in pyproject.toml,
regenerates the lockfile, commits to a branch, pushes, opens a PR
against main, then polls until the PR is merged (or closed).
Clones the repo, updates the crewai[tools] pin in pyproject.toml
and any crewai[extras] pins in .github/workflows, regenerates the
lockfile, commits to a branch, pushes, opens a PR against main,
then polls until the PR is merged (or closed).
Args:
version: New crewai version string.
@@ -1334,47 +1379,64 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
pyproject = repo_dir / "pyproject.toml"
content = pyproject.read_text()
new_content = _pin_crewai_deps(content, version)
if new_content == content:
pyproject_changed = new_content != content
if pyproject_changed:
pyproject.write_text(new_content)
console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}")
else:
console.print(
"[yellow]Warning:[/yellow] No crewai[tools] pin found to update"
)
updated_workflows = _update_repo_workflows_crewai_pins(repo_dir, version)
for wf in updated_workflows:
console.print(
f"[green]✓[/green] Updated crewai pin in {wf.relative_to(repo_dir)}"
)
if not pyproject_changed and not updated_workflows:
console.print("[yellow]Nothing to update; skipping commit and PR.[/yellow]")
return
pyproject.write_text(new_content)
console.print(f"[green]✓[/green] Updated crewai[tools] pin to {version}")
lock_cmd = [
"uv",
"lock",
"--refresh-package",
"crewai",
"--refresh-package",
"crewai-tools",
paths_to_add: list[str] = [
str(wf.relative_to(repo_dir)) for wf in updated_workflows
]
if is_prerelease:
lock_cmd.append("--prerelease=allow")
max_retries = 10
for attempt in range(1, max_retries + 1):
try:
run_command(lock_cmd, cwd=repo_dir)
break
except subprocess.CalledProcessError:
if attempt == max_retries:
if pyproject_changed:
lock_cmd = [
"uv",
"lock",
"--refresh-package",
"crewai",
"--refresh-package",
"crewai-tools",
]
if is_prerelease:
lock_cmd.append("--prerelease=allow")
max_retries = 10
for attempt in range(1, max_retries + 1):
try:
run_command(lock_cmd, cwd=repo_dir)
break
except subprocess.CalledProcessError:
if attempt == max_retries:
console.print(
f"[red]Error:[/red] uv lock failed after {max_retries} attempts"
)
raise
console.print(
f"[red]Error:[/red] uv lock failed after {max_retries} attempts"
f"[yellow]uv lock failed (attempt {attempt}/{max_retries}),"
f" retrying in {_PYPI_POLL_INTERVAL}s...[/yellow]"
)
raise
console.print(
f"[yellow]uv lock failed (attempt {attempt}/{max_retries}),"
f" retrying in {_PYPI_POLL_INTERVAL}s...[/yellow]"
)
time.sleep(_PYPI_POLL_INTERVAL)
console.print("[green]✓[/green] Lockfile updated")
time.sleep(_PYPI_POLL_INTERVAL)
console.print("[green]✓[/green] Lockfile updated")
paths_to_add.extend(["pyproject.toml", "uv.lock"])
branch = f"chore/bump-crewai-v{version}"
create_or_reset_branch(branch, cwd=repo_dir)
run_command(["git", "add", "pyproject.toml", "uv.lock"], cwd=repo_dir)
run_command(["git", "add", *paths_to_add], cwd=repo_dir)
run_command(
["git", "commit", "-m", f"chore: bump crewai to {version}"],
cwd=repo_dir,

10
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-27T16:00:00Z"
exclude-newer = "2026-04-28T07:00:00Z"
[manifest]
members = [
@@ -1626,7 +1626,7 @@ requires-dist = [
{ name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = "~=2.6.0" },
{ name = "exa-py", marker = "extra == 'exa-py'", specifier = ">=1.8.7" },
{ name = "firecrawl-py", marker = "extra == 'firecrawl-py'", specifier = ">=1.8.0" },
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.41,<4" },
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.47,<4" },
{ name = "hyperbrowser", marker = "extra == 'hyperbrowser'", specifier = ">=0.18.0" },
{ name = "langchain-apify", marker = "extra == 'apify'", specifier = ">=0.1.2,<1.0.0" },
{ name = "linkup-sdk", marker = "extra == 'linkup-sdk'", specifier = ">=0.2.2" },
@@ -2619,14 +2619,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.46"
version = "3.1.47"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" },
]
[[package]]