mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-10 17:35:08 +00:00
Compare commits
22 Commits
1.14.4
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3855076670 | ||
|
|
4af40c64f2 | ||
|
|
e25f6538a8 | ||
|
|
470d4035db | ||
|
|
57d1b338f7 | ||
|
|
01df19b029 | ||
|
|
dca2c3160f | ||
|
|
6494d68ffc | ||
|
|
f579aa53ae | ||
|
|
a23e118b11 | ||
|
|
095f796922 | ||
|
|
bfbdba426f | ||
|
|
a058a3b15b | ||
|
|
184c228ae9 | ||
|
|
c9100cb51d | ||
|
|
17e82743f6 | ||
|
|
3403f3cba9 | ||
|
|
5db72250b2 | ||
|
|
a071838e92 | ||
|
|
cd2b9ee38a | ||
|
|
07c4a30f2e | ||
|
|
b30fdbaa0e |
5
.github/security.md
vendored
5
.github/security.md
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. **تعريف الحالة الفريد**
|
||||
|
||||
@@ -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`؛ اختر مصدر ترطيب واحدًا.
|
||||
|
||||
## الخلاصة
|
||||
|
||||
- **ابدأ بتدفق.**
|
||||
|
||||
@@ -133,7 +133,7 @@ crew.kickoff()
|
||||
| **DirectorySearchTool** | أداة RAG للبحث في المجلدات، مفيدة للتنقل في أنظمة الملفات. |
|
||||
| **DOCXSearchTool** | أداة RAG للبحث في مستندات DOCX، مثالية لمعالجة ملفات Word. |
|
||||
| **DirectoryReadTool** | تسهّل قراءة ومعالجة هياكل المجلدات ومحتوياتها. |
|
||||
| **EXASearchTool** | أداة مصممة لإجراء عمليات بحث شاملة عبر مصادر بيانات متنوعة. |
|
||||
| **ExaSearchTool** | أداة مصممة لإجراء عمليات بحث شاملة عبر مصادر بيانات متنوعة. |
|
||||
| **FileReadTool** | تُمكّن قراءة واستخراج البيانات من الملفات، مع دعم تنسيقات ملفات متنوعة. |
|
||||
| **FirecrawlSearchTool** | أداة للبحث في صفحات الويب باستخدام Firecrawl وإرجاع النتائج. |
|
||||
| **FirecrawlCrawlWebsiteTool** | أداة لزحف صفحات الويب باستخدام Firecrawl. |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
## أنماط حالة متقدمة
|
||||
|
||||
### المنطق الشرطي المبني على الحالة
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. **고유 상태 식별**
|
||||
|
||||
@@ -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로 시작하세요.**
|
||||
|
||||
@@ -132,7 +132,7 @@ crew.kickoff()
|
||||
| **DirectorySearchTool** | 디렉터리 내에서 검색하는 RAG 도구로, 파일 시스템을 탐색할 때 유용합니다. |
|
||||
| **DOCXSearchTool** | DOCX 문서 내에서 검색하는 데 특화된 RAG 도구로, Word 파일을 처리할 때 이상적입니다. |
|
||||
| **DirectoryReadTool** | 디렉터리 구조와 그 내용을 읽고 처리하도록 지원하는 도구입니다. |
|
||||
| **EXASearchTool** | 다양한 데이터 소스를 폭넓게 검색하기 위해 설계된 도구입니다. |
|
||||
| **ExaSearchTool** | 다양한 데이터 소스를 폭넓게 검색하기 위해 설계된 도구입니다. |
|
||||
| **FileReadTool** | 다양한 파일 형식을 지원하며 파일에서 데이터를 읽고 추출할 수 있는 도구입니다. |
|
||||
| **FirecrawlSearchTool** | Firecrawl을 이용해 웹페이지를 검색하고 결과를 반환하는 도구입니다. |
|
||||
| **FirecrawlCrawlWebsiteTool** | Firecrawl을 사용해 웹페이지를 크롤링하는 도구입니다. |
|
||||
|
||||
@@ -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`만 사용하는 것이 좋습니다.
|
||||
|
||||
## 고급 상태 패턴
|
||||
|
||||
### 상태 기반 조건부 로직
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.4"
|
||||
__version__ = "1.14.5a2"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
147
lib/crewai/src/crewai/cli/skill_proposals_tui.py
Normal file
147
lib/crewai/src/crewai/cli/skill_proposals_tui.py
Normal 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()
|
||||
249
lib/crewai/src/crewai/cli/skills_proposals.py
Normal file
249
lib/crewai/src/crewai/cli/skills_proposals.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
lib/crewai/src/crewai/skills/self_improve/__init__.py
Normal file
38
lib/crewai/src/crewai/skills/self_improve/__init__.py
Normal 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",
|
||||
]
|
||||
104
lib/crewai/src/crewai/skills/self_improve/acceptance.py
Normal file
104
lib/crewai/src/crewai/skills/self_improve/acceptance.py
Normal 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)
|
||||
76
lib/crewai/src/crewai/skills/self_improve/auto_grade.py
Normal file
76
lib/crewai/src/crewai/skills/self_improve/auto_grade.py
Normal 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"
|
||||
298
lib/crewai/src/crewai/skills/self_improve/collector.py
Normal file
298
lib/crewai/src/crewai/skills/self_improve/collector.py
Normal 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
|
||||
)
|
||||
156
lib/crewai/src/crewai/skills/self_improve/models.py
Normal file
156
lib/crewai/src/crewai/skills/self_improve/models.py
Normal 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
|
||||
220
lib/crewai/src/crewai/skills/self_improve/reviewer.py
Normal file
220
lib/crewai/src/crewai/skills/self_improve/reviewer.py
Normal 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
|
||||
]
|
||||
177
lib/crewai/src/crewai/skills/self_improve/storage.py
Normal file
177
lib/crewai/src/crewai/skills/self_improve/storage.py
Normal 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()
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:"]
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
0
lib/crewai/tests/skills/self_improve/__init__.py
Normal file
0
lib/crewai/tests/skills/self_improve/__init__.py
Normal file
243
lib/crewai/tests/skills/self_improve/test_acceptance.py
Normal file
243
lib/crewai/tests/skills/self_improve/test_acceptance.py
Normal 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
|
||||
85
lib/crewai/tests/skills/self_improve/test_auto_grade.py
Normal file
85
lib/crewai/tests/skills/self_improve/test_auto_grade.py
Normal 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"
|
||||
103
lib/crewai/tests/skills/self_improve/test_cli_skills.py
Normal file
103
lib/crewai/tests/skills/self_improve/test_cli_skills.py
Normal 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
|
||||
153
lib/crewai/tests/skills/self_improve/test_collector.py
Normal file
153
lib/crewai/tests/skills/self_improve/test_collector.py
Normal 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"]
|
||||
106
lib/crewai/tests/skills/self_improve/test_models.py
Normal file
106
lib/crewai/tests/skills/self_improve/test_models.py
Normal 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,
|
||||
)
|
||||
208
lib/crewai/tests/skills/self_improve/test_reviewer.py
Normal file
208
lib/crewai/tests/skills/self_improve/test_reviewer.py
Normal 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"
|
||||
69
lib/crewai/tests/skills/self_improve/test_storage.py
Normal file
69
lib/crewai/tests/skills/self_improve/test_storage.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.4"
|
||||
__version__ = "1.14.5a2"
|
||||
|
||||
@@ -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
10
uv.lock
generated
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user