mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-21 00:48:10 +00:00
Compare commits
39 Commits
gl/refacto
...
fix/runtim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e9167dec3 | ||
|
|
1eb2326e8a | ||
|
|
a0e5d91364 | ||
|
|
c6643d4071 | ||
|
|
0d5c2c81e0 | ||
|
|
2346f12c43 | ||
|
|
dc047743b8 | ||
|
|
163b3b592d | ||
|
|
83ba64c334 | ||
|
|
418afd29e7 | ||
|
|
fc480409bd | ||
|
|
15a423ad3c | ||
|
|
c37afab1ff | ||
|
|
f385b91a63 | ||
|
|
0991f7994a | ||
|
|
3ceb9a287a | ||
|
|
0f3a57b3b9 | ||
|
|
b07c1439a3 | ||
|
|
97e959cb0c | ||
|
|
752d9b45d6 | ||
|
|
7cc1a7bb41 | ||
|
|
09ffe87fbb | ||
|
|
14af56b74d | ||
|
|
35f693cf68 | ||
|
|
da15554d81 | ||
|
|
284533464f | ||
|
|
024e230b2c | ||
|
|
a4c90b6912 | ||
|
|
c50da7a6f2 | ||
|
|
e8aa870f90 | ||
|
|
14cd81eec6 | ||
|
|
a6225da326 | ||
|
|
259d334e38 | ||
|
|
42aa8a777c | ||
|
|
a95d26763f | ||
|
|
65ec783aae | ||
|
|
eefe0e42ac | ||
|
|
75bb882911 | ||
|
|
c36827b45b |
2
.github/workflows/build-uv-cache.yml
vendored
2
.github/workflows/build-uv-cache.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
8
.github/workflows/generate-tool-specs.yml
vendored
8
.github/workflows/generate-tool-specs.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app_id: ${{ secrets.CREWAI_TOOL_SPECS_APP_ID }}
|
||||
private_key: ${{ secrets.CREWAI_TOOL_SPECS_PRIVATE_KEY }}
|
||||
app-id: ${{ secrets.CREWAI_TOOL_SPECS_APP_ID }}
|
||||
private-key: ${{ secrets.CREWAI_TOOL_SPECS_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.12"
|
||||
|
||||
4
.github/workflows/linter.yml
vendored
4
.github/workflows/linter.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
uv-main-py3.11-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.11"
|
||||
|
||||
4
.github/workflows/nightly.yml
vendored
4
.github/workflows/nightly.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.12"
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.12"
|
||||
|
||||
2
.github/workflows/pr-size.yml
vendored
2
.github/workflows/pr-size.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: codelytv/pr-size-labeler@v1
|
||||
- uses: codelytv/pr-size-labeler@095a41fca88b8764fd9e008ad269bcdb82bb38b9 # v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
xs_label: "size/XS"
|
||||
|
||||
2
.github/workflows/pr-title.yml
vendored
2
.github/workflows/pr-title.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/publish.yml
vendored
6
.github/workflows/publish.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.12"
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Notify Slack
|
||||
if: success()
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
uv-main-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/type-checker.yml
vendored
4
.github/workflows/type-checker.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
uv-main-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
2
.github/workflows/update-test-durations.yml
vendored
2
.github/workflows/update-test-durations.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
uv-main-py${{ matrix.python-version }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
38
.github/workflows/vulnerability-scan.yml
vendored
38
.github/workflows/vulnerability-scan.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
uv-main-py3.11-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.11"
|
||||
@@ -46,11 +46,39 @@ jobs:
|
||||
- name: Run pip-audit
|
||||
run: |
|
||||
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json \
|
||||
--ignore-vuln CVE-2026-3219 \
|
||||
--ignore-vuln GHSA-r374-rxx8-8654
|
||||
--ignore-vuln PYSEC-2024-277 \
|
||||
--ignore-vuln PYSEC-2026-89 \
|
||||
--ignore-vuln PYSEC-2026-97 \
|
||||
--ignore-vuln PYSEC-2025-148 \
|
||||
--ignore-vuln PYSEC-2025-183 \
|
||||
--ignore-vuln PYSEC-2025-189 \
|
||||
--ignore-vuln PYSEC-2025-190 \
|
||||
--ignore-vuln PYSEC-2025-191 \
|
||||
--ignore-vuln PYSEC-2025-192 \
|
||||
--ignore-vuln PYSEC-2025-193 \
|
||||
--ignore-vuln PYSEC-2025-194 \
|
||||
--ignore-vuln PYSEC-2025-195 \
|
||||
--ignore-vuln PYSEC-2025-196 \
|
||||
--ignore-vuln PYSEC-2025-197 \
|
||||
--ignore-vuln PYSEC-2025-210 \
|
||||
--ignore-vuln PYSEC-2026-139 \
|
||||
--ignore-vuln PYSEC-2025-211 \
|
||||
--ignore-vuln PYSEC-2025-212 \
|
||||
--ignore-vuln PYSEC-2025-213 \
|
||||
--ignore-vuln PYSEC-2025-214 \
|
||||
--ignore-vuln PYSEC-2025-215 \
|
||||
--ignore-vuln PYSEC-2025-216 \
|
||||
--ignore-vuln PYSEC-2025-217 \
|
||||
--ignore-vuln PYSEC-2025-218
|
||||
# Ignored CVEs:
|
||||
# CVE-2026-3219 - pip 26.0.1 (GHSA-58qw-9mgm-455v): no fix available, archive handling issue
|
||||
# GHSA-r374-rxx8-8654 - paramiko 4.0.0 (SHA-1 in rsakey.py): no fix available; transitive via composio-core
|
||||
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
|
||||
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
|
||||
# PYSEC-2026-97 - nltk 3.9.4: arbitrary file read in filestring(); no fix available
|
||||
# PYSEC-2025-148 - onnx 1.21.0: path traversal in save_external_data; no fix available
|
||||
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
|
||||
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available
|
||||
# PYSEC-2025-210, PYSEC-2026-139 - torch 2.11.0: profiler/deserialization issues; no fix available
|
||||
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
|
||||
continue-on-error: true
|
||||
|
||||
- name: Display results
|
||||
|
||||
@@ -4,6 +4,86 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="19 مايو 2026">
|
||||
## v1.14.5
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إلغاء استخدام `CrewAgentExecutor`، وتعيين وكلاء الطاقم الافتراضيين إلى `AgentExecutor`
|
||||
- تحسين أدوات صندوق الرمل Daytona
|
||||
- إضافة معلمة بدء `restore_from_state_id`
|
||||
- إضافة تسليط الضوء على `ExaSearchTool`، وإعادة تسميته من `EXASearchTool`
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح تسرب الذاكرة في `git.py` باستخدام `cached_property`
|
||||
- عرض استدعاءات الأدوات المتدفقة عندما تكون `available_functions` غائبة
|
||||
- ضمان تحميل أحداث `skills` للتتبع
|
||||
- تصحيح مسار نقطة النهاية للحالة من `/{kickoff_id}/status` إلى `/status/{kickoff_id}`
|
||||
- استعادة كتلة الشيفرة المفقودة في دليل التدفق الأول للغة البرتغالية (pt-BR)
|
||||
- منع `result_as_answer` من إرجاع رسائل الخطأ أو الكتل المرتبطة كإجابة نهائية
|
||||
- الحفاظ على مخرجات المهام عبر تفريغ الدفعات غير المتزامنة
|
||||
- دائمًا استعادة `task.output_pydantic` في كتلة finally
|
||||
- التعامل مع إدخال `BaseModel` في `convert_to_model`
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5
|
||||
- إضافة دليل ترقية OSS و انتقال الطاقم إلى التدفق
|
||||
- توثيق متغيرات البيئة الإضافية لأدوات المطور
|
||||
- إضافة وثائق لـ `TavilyGetResearch`
|
||||
|
||||
### إعادة الهيكلة
|
||||
- استخراج واجهة سطر الأوامر إلى حزمة مستقلة `crewai-cli`
|
||||
|
||||
## المساهمون
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="18 مايو 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5a6
|
||||
|
||||
### تغييرات كسرية
|
||||
- إلغاء حقل function_calling_llm
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="15 مايو 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- إصلاح استدعاءات الأدوات المتدفقة عندما تكون available_functions غائبة
|
||||
- رفع اعتماد langsmith إلى الإصدار >=0.8.0 لمعالجة GHSA-3644-q5cj-c5c7
|
||||
- حل مشاكل الأماكن الشاغرة لكتل التعليمات البرمجية غير المترجمة في وثائق البرتغالية البرازيلية
|
||||
|
||||
### الوثائق
|
||||
- إضافة وثائق لـ TavilyGetResearch
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5a5
|
||||
|
||||
## المساهمون
|
||||
|
||||
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="13 مايو 2026">
|
||||
## v1.14.5a5
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
|
||||
from dotenv import load_dotenv
|
||||
from litellm import completion
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class ExampleFlow(Flow):
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
@@ -146,7 +146,6 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="الإجابة على الأسئلة">
|
||||
أجب على أسئلة التوضيح من مساعد الطاقم لتنقيح
|
||||
متطلباتك.
|
||||
@@ -161,12 +160,10 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="الموافقة أو التعديل">
|
||||
وافق على الخطة أو اطلب تغييرات إذا لزم الأمر.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="التنزيل أو النشر">
|
||||
نزّل الكود للتخصيص أو انشر مباشرة على المنصة.
|
||||
</Step>
|
||||
|
||||
@@ -802,7 +802,6 @@ The tables below show a representative sample of current top-performing models a
|
||||
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identify Specialized Needs">
|
||||
Determine if your crew has specific requirements (coding, reasoning, speed)
|
||||
that would benefit from specialized models like **Claude 4 Sonnet** for
|
||||
@@ -810,7 +809,6 @@ The tables below show a representative sample of current top-performing models a
|
||||
consider fast inference providers like **Groq** alongside model selection.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implement Multi-Model Strategy">
|
||||
Use different models for different agents based on their roles.
|
||||
High-capability models for managers and complex tasks, efficient models for
|
||||
|
||||
1899
docs/docs.json
1899
docs/docs.json
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,86 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="May 19, 2026">
|
||||
## v1.14.5
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Deprecate `CrewAgentExecutor`, default Crew agents to `AgentExecutor`
|
||||
- Improve Daytona sandbox tools
|
||||
- Add `restore_from_state_id` kickoff parameter
|
||||
- Add highlights to `ExaSearchTool`, rename from `EXASearchTool`
|
||||
|
||||
### Bug Fixes
|
||||
- Fix memory leak in `git.py` by using `cached_property`
|
||||
- Surface streamed tool calls when `available_functions` is absent
|
||||
- Ensure `skills` loading events for traces
|
||||
- Correct status endpoint path from `/{kickoff_id}/status` to `/status/{kickoff_id}`
|
||||
- Restore missing code block in pt-BR first-flow guide
|
||||
- Prevent `result_as_answer` from returning hook-block or error messages as final answer
|
||||
- Preserve task outputs across async batch flush
|
||||
- Always restore `task.output_pydantic` in finally block
|
||||
- Handle `BaseModel` input in `convert_to_model`
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.5
|
||||
- Add OSS upgrade & crew-to-flow migration guide
|
||||
- Document additional env vars for devtools
|
||||
- Add docs for `TavilyGetResearch`
|
||||
|
||||
### Refactoring
|
||||
- Extract CLI into standalone `crewai-cli` package
|
||||
|
||||
## Contributors
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 18, 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.5a6
|
||||
|
||||
### Breaking Changes
|
||||
- Deprecate function_calling_llm field
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 15, 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Bug Fixes
|
||||
- Fix streamed tool calls when available_functions is absent
|
||||
- Bump langsmith dependency to version >=0.8.0 to address GHSA-3644-q5cj-c5c7
|
||||
- Resolve untranslated code block placeholders in Brazilian Portuguese documentation
|
||||
|
||||
### Documentation
|
||||
- Add documentation for TavilyGetResearch
|
||||
- Update changelog and version for v1.14.5a5
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 13, 2026">
|
||||
## v1.14.5a5
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
|
||||
from dotenv import load_dotenv
|
||||
from litellm import completion
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class ExampleFlow(Flow):
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
@@ -146,7 +146,6 @@ Here's a typical workflow for creating a crew with Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Answer Questions">
|
||||
Respond to clarifying questions from the Crew Assistant to refine your
|
||||
requirements.
|
||||
@@ -161,12 +160,10 @@ Here's a typical workflow for creating a crew with Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Approve or Modify">
|
||||
Approve the plan or request changes if necessary.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Download or Deploy">
|
||||
Download the code for customization or deploy directly to the platform.
|
||||
</Step>
|
||||
|
||||
@@ -313,9 +313,9 @@ flow1 = PersistentCounterFlow()
|
||||
result1 = flow1.kickoff()
|
||||
print(f"First run result: {result1}")
|
||||
|
||||
# Second run - state is automatically loaded
|
||||
# Second run - pass the ID to load the persisted state
|
||||
flow2 = PersistentCounterFlow()
|
||||
result2 = flow2.kickoff()
|
||||
result2 = flow2.kickoff(inputs={"id": flow1.state.id})
|
||||
print(f"Second run result: {result2}") # Will be higher due to persisted state
|
||||
```
|
||||
|
||||
|
||||
@@ -805,7 +805,6 @@ The tables below show a representative sample of current top-performing models a
|
||||
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identify Specialized Needs">
|
||||
Determine if your crew has specific requirements (coding, reasoning, speed)
|
||||
that would benefit from specialized models like **Claude 4 Sonnet** for
|
||||
@@ -813,7 +812,6 @@ The tables below show a representative sample of current top-performing models a
|
||||
consider fast inference providers like **Groq** alongside model selection.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implement Multi-Model Strategy">
|
||||
Use different models for different agents based on their roles.
|
||||
High-capability models for managers and complex tasks, efficient models for
|
||||
|
||||
@@ -4,6 +4,86 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 5월 19일">
|
||||
## v1.14.5
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- `CrewAgentExecutor` 사용 중단, 기본 Crew 에이전트를 `AgentExecutor`로 설정
|
||||
- Daytona 샌드박스 도구 개선
|
||||
- `restore_from_state_id` 시작 매개변수 추가
|
||||
- `ExaSearchTool`에 하이라이트 추가, 이름을 `EXASearchTool`에서 변경
|
||||
|
||||
### 버그 수정
|
||||
- `git.py`에서 `cached_property`를 사용하여 메모리 누수 수정
|
||||
- `available_functions`가 없을 때 스트리밍 도구 호출 표시
|
||||
- 추적을 위한 `skills` 로딩 이벤트 보장
|
||||
- 상태 엔드포인트 경로를 `/{kickoff_id}/status`에서 `/status/{kickoff_id}`로 수정
|
||||
- pt-BR 첫 흐름 가이드에서 누락된 코드 블록 복원
|
||||
- `result_as_answer`가 후크 블록이나 오류 메시지를 최종 답변으로 반환하지 않도록 방지
|
||||
- 비동기 배치 플러시 간 작업 출력 보존
|
||||
- 항상 finally 블록에서 `task.output_pydantic` 복원
|
||||
- `convert_to_model`에서 `BaseModel` 입력 처리
|
||||
|
||||
### 문서화
|
||||
- v1.14.5에 대한 변경 로그 및 버전 업데이트
|
||||
- OSS 업그레이드 및 Crew-투-흐름 마이그레이션 가이드 추가
|
||||
- 개발 도구를 위한 추가 환경 변수 문서화
|
||||
- `TavilyGetResearch`에 대한 문서 추가
|
||||
|
||||
### 리팩토링
|
||||
- CLI를 독립형 `crewai-cli` 패키지로 추출
|
||||
|
||||
## 기여자
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 18일">
|
||||
## v1.14.5a7
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 문서
|
||||
- v1.14.5a6의 변경 로그 및 버전 업데이트
|
||||
|
||||
### 주요 변경 사항
|
||||
- function_calling_llm 필드 사용 중단
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 15일">
|
||||
## v1.14.5a6
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 버그 수정
|
||||
- available_functions가 없을 때 스트리밍 도구 호출 수정
|
||||
- GHSA-3644-q5cj-c5c7 문제를 해결하기 위해 langsmith 의존성을 버전 >=0.8.0으로 업데이트
|
||||
- 브라질 포르투갈어 문서에서 번역되지 않은 코드 블록 자리 표시자 해결
|
||||
|
||||
### 문서
|
||||
- TavilyGetResearch에 대한 문서 추가
|
||||
- v1.14.5a5에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 13일">
|
||||
## v1.14.5a5
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
|
||||
from dotenv import load_dotenv
|
||||
from litellm import completion
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class ExampleFlow(Flow):
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
@@ -145,7 +145,6 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="질문에 답하기">
|
||||
crew assistant가 요구 사항을 구체화할 수 있도록 하는 추가 질문에 답변하세요.
|
||||
</Step>
|
||||
@@ -159,12 +158,10 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="승인 또는 수정">
|
||||
계획을 승인하거나 필요하다면 변경을 요청하세요.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="다운로드 또는 배포">
|
||||
사용자화를 위해 코드를 다운로드하거나 플랫폼에 직접 배포하세요.
|
||||
</Step>
|
||||
|
||||
@@ -797,7 +797,6 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
|
||||
여러 차원에서 우수한 성능을 제공하며 실제 환경에서 광범위하게 검증된 **GPT-4.1**, **Claude 3.7 Sonnet**, **Gemini 2.0 Flash**와 같은 잘 알려진 모델부터 시작하십시오.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="특화된 요구 사항 식별">
|
||||
crew에 코드 작성, reasoning, 속도 등 특정 요구가 있는지 확인하고, 이러한
|
||||
요구에 부합하는 **Claude 4 Sonnet**(개발용) 또는 **o3**(복잡한 분석용)과 같은
|
||||
@@ -805,7 +804,6 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
|
||||
더불어 **Groq**와 같은 빠른 추론 제공자를 고려할 수 있습니다.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="다중 모델 전략 구현">
|
||||
각 에이전트의 역할에 따라 다양한 모델을 사용하세요. 관리자와 복잡한 작업에는
|
||||
고성능 모델을, 일상적 운영에는 효율적인 모델을 적용합니다.
|
||||
|
||||
@@ -4,6 +4,86 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="19 mai 2026">
|
||||
## v1.14.5
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Deprecar `CrewAgentExecutor`, definir agentes Crew como `AgentExecutor`
|
||||
- Melhorar ferramentas do sandbox Daytona
|
||||
- Adicionar parâmetro de início `restore_from_state_id`
|
||||
- Adicionar destaques ao `ExaSearchTool`, renomeando de `EXASearchTool`
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir vazamento de memória em `git.py` usando `cached_property`
|
||||
- Exibir chamadas de ferramentas transmitidas quando `available_functions` está ausente
|
||||
- Garantir eventos de carregamento de `skills` para rastros
|
||||
- Corrigir caminho do endpoint de status de `/{kickoff_id}/status` para `/status/{kickoff_id}`
|
||||
- Restaurar bloco de código ausente no guia de primeiro fluxo em pt-BR
|
||||
- Impedir que `result_as_answer` retorne mensagens de bloqueio de hook ou de erro como resposta final
|
||||
- Preservar saídas de tarefas durante o descarregamento assíncrono em lote
|
||||
- Sempre restaurar `task.output_pydantic` no bloco finally
|
||||
- Lidar com entrada de `BaseModel` em `convert_to_model`
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.5
|
||||
- Adicionar guia de migração de atualização OSS & crew-to-flow
|
||||
- Documentar variáveis de ambiente adicionais para devtools
|
||||
- Adicionar documentação para `TavilyGetResearch`
|
||||
|
||||
### Refatoração
|
||||
- Extrair CLI para o pacote autônomo `crewai-cli`
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="18 mai 2026">
|
||||
## v1.14.5a7
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.5a6
|
||||
|
||||
### Mudanças Quebradoras
|
||||
- Depreciar o campo function_calling_llm
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="15 mai 2026">
|
||||
## v1.14.5a6
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
|
||||
|
||||
## O que mudou
|
||||
|
||||
### Correções de Bugs
|
||||
- Corrigir chamadas de ferramentas transmitidas quando available_functions está ausente
|
||||
- Atualizar a dependência langsmith para a versão >=0.8.0 para resolver GHSA-3644-q5cj-c5c7
|
||||
- Resolver espaços reservados de blocos de código não traduzidos na documentação em português brasileiro
|
||||
|
||||
### Documentação
|
||||
- Adicionar documentação para TavilyGetResearch
|
||||
- Atualizar changelog e versão para v1.14.5a5
|
||||
|
||||
## Contributors
|
||||
|
||||
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="13 mai 2026">
|
||||
## v1.14.5a5
|
||||
|
||||
|
||||
@@ -24,7 +24,63 @@ Os flows permitem que você crie fluxos de trabalho estruturados e orientados po
|
||||
Vamos criar um Flow simples no qual você usará a OpenAI para gerar uma cidade aleatória em uma tarefa e, em seguida, usará essa cidade para gerar uma curiosidade em outra tarefa.
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from dotenv import load_dotenv
|
||||
from litellm import completion
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class ExampleFlow(Flow):
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
@start()
|
||||
def generate_city(self):
|
||||
print("Starting flow")
|
||||
# Cada estado do flow recebe automaticamente um ID único
|
||||
print(f"Flow State ID: {self.state['id']}")
|
||||
|
||||
response = completion(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Return the name of a random city in the world.",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
random_city = response["choices"][0]["message"]["content"]
|
||||
# Armazena a cidade no nosso estado
|
||||
self.state["city"] = random_city
|
||||
print(f"Random City: {random_city}")
|
||||
|
||||
return random_city
|
||||
|
||||
@listen(generate_city)
|
||||
def generate_fun_fact(self, random_city):
|
||||
response = completion(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Tell me a fun fact about {random_city}",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
fun_fact = response["choices"][0]["message"]["content"]
|
||||
# Armazena a curiosidade no nosso estado
|
||||
self.state["fun_fact"] = fun_fact
|
||||
return fun_fact
|
||||
|
||||
|
||||
|
||||
flow = ExampleFlow()
|
||||
flow.plot()
|
||||
result = flow.kickoff()
|
||||
|
||||
print(f"Generated fun fact: {result}")
|
||||
```
|
||||
|
||||
Na ilustração acima, criamos um Flow simples que gera uma cidade aleatória usando a OpenAI e depois cria uma curiosidade sobre essa cidade. O Flow consiste em duas tarefas: `generate_city` e `generate_fun_fact`. A tarefa `generate_city` é o ponto de início do Flow, enquanto a tarefa `generate_fun_fact` fica escutando o resultado da tarefa `generate_city`.
|
||||
@@ -56,12 +112,16 @@ O decorador `@listen()` pode ser usado de várias formas:
|
||||
1. **Escutando um Método pelo Nome**: Você pode passar o nome do método ao qual deseja escutar como string. Quando esse método concluir, o método ouvinte será chamado.
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
@listen("generate_city")
|
||||
def generate_fun_fact(self, random_city):
|
||||
# Implementação
|
||||
```
|
||||
|
||||
2. **Escutando um Método Diretamente**: Você pode passar o próprio método. Quando esse método concluir, o método ouvinte será chamado.
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
@listen(generate_city)
|
||||
def generate_fun_fact(self, random_city):
|
||||
# Implementação
|
||||
```
|
||||
|
||||
### Saída de um Flow
|
||||
@@ -76,7 +136,24 @@ Veja como acessar a saída final:
|
||||
|
||||
<CodeGroup>
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class OutputExampleFlow(Flow):
|
||||
@start()
|
||||
def first_method(self):
|
||||
return "Output from first_method"
|
||||
|
||||
@listen(first_method)
|
||||
def second_method(self, first_output):
|
||||
return f"Second method received: {first_output}"
|
||||
|
||||
|
||||
flow = OutputExampleFlow()
|
||||
flow.plot("my_flow_plot")
|
||||
final_output = flow.kickoff()
|
||||
|
||||
print("---- Final Output ----")
|
||||
print(final_output)
|
||||
```
|
||||
|
||||
```text Output
|
||||
@@ -97,8 +174,34 @@ Além de recuperar a saída final, você pode acessar e atualizar o estado dentr
|
||||
Veja um exemplo de como atualizar e acessar o estado:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ExampleState(BaseModel):
|
||||
counter: int = 0
|
||||
message: str = ""
|
||||
|
||||
class StateExampleFlow(Flow[ExampleState]):
|
||||
|
||||
@start()
|
||||
def first_method(self):
|
||||
self.state.message = "Hello from first_method"
|
||||
self.state.counter += 1
|
||||
|
||||
@listen(first_method)
|
||||
def second_method(self):
|
||||
self.state.message += " - updated by second_method"
|
||||
self.state.counter += 1
|
||||
return self.state.message
|
||||
|
||||
flow = StateExampleFlow()
|
||||
flow.plot("my_flow_plot")
|
||||
final_output = flow.kickoff()
|
||||
print(f"Final Output: {final_output}")
|
||||
print("Final State:")
|
||||
print(flow.state)
|
||||
```
|
||||
|
||||
```text Output
|
||||
@@ -128,7 +231,33 @@ Essa abordagem oferece flexibilidade, permitindo que o desenvolvedor adicione ou
|
||||
Mesmo com estados não estruturados, os flows do CrewAI geram e mantêm automaticamente um identificador único (UUID) para cada instância de estado.
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UnstructuredExampleFlow(Flow):
|
||||
|
||||
@start()
|
||||
def first_method(self):
|
||||
# O estado inclui automaticamente um campo 'id'
|
||||
print(f"State ID: {self.state['id']}")
|
||||
self.state['counter'] = 0
|
||||
self.state['message'] = "Hello from structured flow"
|
||||
|
||||
@listen(first_method)
|
||||
def second_method(self):
|
||||
self.state['counter'] += 1
|
||||
self.state['message'] += " - updated"
|
||||
|
||||
@listen(second_method)
|
||||
def third_method(self):
|
||||
self.state['counter'] += 1
|
||||
self.state['message'] += " - updated again"
|
||||
|
||||
print(f"State after third_method: {self.state}")
|
||||
|
||||
|
||||
flow = UnstructuredExampleFlow()
|
||||
flow.plot("my_flow_plot")
|
||||
flow.kickoff()
|
||||
```
|
||||
|
||||

|
||||
@@ -148,7 +277,39 @@ Ao usar modelos como o `BaseModel` da Pydantic, os desenvolvedores podem definir
|
||||
Cada estado nos flows do CrewAI recebe automaticamente um identificador único (UUID) para ajudar no rastreamento e gerenciamento. Esse ID é gerado e mantido automaticamente pelo sistema de flows.
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ExampleState(BaseModel):
|
||||
# Nota: o campo 'id' é adicionado automaticamente a todos os estados
|
||||
counter: int = 0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class StructuredExampleFlow(Flow[ExampleState]):
|
||||
|
||||
@start()
|
||||
def first_method(self):
|
||||
# Acesse o ID gerado automaticamente, se necessário
|
||||
print(f"State ID: {self.state.id}")
|
||||
self.state.message = "Hello from structured flow"
|
||||
|
||||
@listen(first_method)
|
||||
def second_method(self):
|
||||
self.state.counter += 1
|
||||
self.state.message += " - updated"
|
||||
|
||||
@listen(second_method)
|
||||
def third_method(self):
|
||||
self.state.counter += 1
|
||||
self.state.message += " - updated again"
|
||||
|
||||
print(f"State after third_method: {self.state}")
|
||||
|
||||
|
||||
flow = StructuredExampleFlow()
|
||||
flow.kickoff()
|
||||
```
|
||||
|
||||

|
||||
@@ -182,7 +343,19 @@ O decorador @persist permite a persistência automática do estado nos flows do
|
||||
Quando aplicado no nível da classe, o decorador @persist garante a persistência automática de todos os estados dos métodos do flow:
|
||||
|
||||
```python
|
||||
# (O código não é traduzido)
|
||||
@persist # Usa SQLiteFlowPersistence por padrão
|
||||
class MyFlow(Flow[MyState]):
|
||||
@start()
|
||||
def initialize_flow(self):
|
||||
# Este método terá seu estado persistido automaticamente
|
||||
self.state.counter = 1
|
||||
print("Initialized flow. State ID:", self.state.id)
|
||||
|
||||
@listen(initialize_flow)
|
||||
def next_step(self):
|
||||
# O estado (incluindo self.state.id) é recarregado automaticamente
|
||||
self.state.counter += 1
|
||||
print("Flow state is persisted. Counter:", self.state.counter)
|
||||
```
|
||||
|
||||
### Persistência no Nível de Método
|
||||
@@ -190,7 +363,14 @@ Quando aplicado no nível da classe, o decorador @persist garante a persistênci
|
||||
Para um controle mais granular, você pode aplicar @persist em métodos específicos:
|
||||
|
||||
```python
|
||||
# (O código não é traduzido)
|
||||
class AnotherFlow(Flow[dict]):
|
||||
@persist # Persiste apenas o estado deste método
|
||||
@start()
|
||||
def begin(self):
|
||||
if "runs" not in self.state:
|
||||
self.state["runs"] = 0
|
||||
self.state["runs"] += 1
|
||||
print("Method-level persisted runs:", self.state["runs"])
|
||||
```
|
||||
|
||||
### Forking de Estado Persistido
|
||||
@@ -282,8 +462,29 @@ A arquitetura de persistência enfatiza precisão técnica e opções de persona
|
||||
A função `or_` nos flows permite escutar múltiplos métodos e acionar o método ouvinte quando qualquer um dos métodos especificados gerar uma saída.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, listen, or_, start
|
||||
|
||||
class OrExampleFlow(Flow):
|
||||
|
||||
@start()
|
||||
def start_method(self):
|
||||
return "Hello from the start method"
|
||||
|
||||
@listen(start_method)
|
||||
def second_method(self):
|
||||
return "Hello from the second method"
|
||||
|
||||
@listen(or_(start_method, second_method))
|
||||
def logger(self, result):
|
||||
print(f"Logger: {result}")
|
||||
|
||||
|
||||
|
||||
flow = OrExampleFlow()
|
||||
flow.plot("my_flow_plot")
|
||||
flow.kickoff()
|
||||
```
|
||||
|
||||
```text Output
|
||||
@@ -302,8 +503,28 @@ A função `or_` serve para escutar vários métodos e disparar o método ouvint
|
||||
A função `and_` nos flows permite escutar múltiplos métodos e acionar o método ouvinte apenas quando todos os métodos especificados emitirem uma saída.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
from crewai.flow.flow import Flow, and_, listen, start
|
||||
|
||||
class AndExampleFlow(Flow):
|
||||
|
||||
@start()
|
||||
def start_method(self):
|
||||
self.state["greeting"] = "Hello from the start method"
|
||||
|
||||
@listen(start_method)
|
||||
def second_method(self):
|
||||
self.state["joke"] = "What do computers eat? Microchips."
|
||||
|
||||
@listen(and_(start_method, second_method))
|
||||
def logger(self):
|
||||
print("---- Logger ----")
|
||||
print(self.state)
|
||||
|
||||
flow = AndExampleFlow()
|
||||
flow.plot()
|
||||
flow.kickoff()
|
||||
```
|
||||
|
||||
```text Output
|
||||
@@ -323,8 +544,42 @@ O decorador `@router()` nos flows permite definir lógica de roteamento condicio
|
||||
Você pode especificar diferentes rotas conforme a saída do método, permitindo controlar o fluxo de execução de forma dinâmica.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
import random
|
||||
from crewai.flow.flow import Flow, listen, router, start
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ExampleState(BaseModel):
|
||||
success_flag: bool = False
|
||||
|
||||
class RouterFlow(Flow[ExampleState]):
|
||||
|
||||
@start()
|
||||
def start_method(self):
|
||||
print("Starting the structured flow")
|
||||
random_boolean = random.choice([True, False])
|
||||
self.state.success_flag = random_boolean
|
||||
|
||||
@router(start_method)
|
||||
def second_method(self):
|
||||
if self.state.success_flag:
|
||||
return "success"
|
||||
else:
|
||||
return "failed"
|
||||
|
||||
@listen("success")
|
||||
def third_method(self):
|
||||
print("Third method running")
|
||||
|
||||
@listen("failed")
|
||||
def fourth_method(self):
|
||||
print("Fourth method running")
|
||||
|
||||
|
||||
flow = RouterFlow()
|
||||
flow.plot("my_flow_plot")
|
||||
flow.kickoff()
|
||||
```
|
||||
|
||||
```text Output
|
||||
@@ -401,7 +656,105 @@ Para um guia completo sobre feedback humano em flows, incluindo feedback assínc
|
||||
Os agentes podem ser integrados facilmente aos seus flows, oferecendo uma alternativa leve às crews completas quando você precisar executar tarefas simples e focadas. Veja um exemplo de como utilizar um agente em um flow para realizar uma pesquisa de mercado:
|
||||
|
||||
```python
|
||||
# (O código não é traduzido)
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from crewai_tools import SerperDevTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai.agent import Agent
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
|
||||
# Define um formato de saída estruturado
|
||||
class MarketAnalysis(BaseModel):
|
||||
key_trends: List[str] = Field(description="List of identified market trends")
|
||||
market_size: str = Field(description="Estimated market size")
|
||||
competitors: List[str] = Field(description="Major competitors in the space")
|
||||
|
||||
|
||||
# Define o estado do flow
|
||||
class MarketResearchState(BaseModel):
|
||||
product: str = ""
|
||||
analysis: MarketAnalysis | None = None
|
||||
|
||||
|
||||
# Cria uma classe de flow
|
||||
class MarketResearchFlow(Flow[MarketResearchState]):
|
||||
@start()
|
||||
def initialize_research(self) -> Dict[str, Any]:
|
||||
print(f"Starting market research for {self.state.product}")
|
||||
return {"product": self.state.product}
|
||||
|
||||
@listen(initialize_research)
|
||||
async def analyze_market(self) -> Dict[str, Any]:
|
||||
# Cria um agente para pesquisa de mercado
|
||||
analyst = Agent(
|
||||
role="Market Research Analyst",
|
||||
goal=f"Analyze the market for {self.state.product}",
|
||||
backstory="You are an experienced market analyst with expertise in "
|
||||
"identifying market trends and opportunities.",
|
||||
tools=[SerperDevTool()],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Define a consulta de pesquisa
|
||||
query = f"""
|
||||
Research the market for {self.state.product}. Include:
|
||||
1. Key market trends
|
||||
2. Market size
|
||||
3. Major competitors
|
||||
|
||||
Format your response according to the specified structure.
|
||||
"""
|
||||
|
||||
# Executa a análise com formato de saída estruturado
|
||||
result = await analyst.kickoff_async(query, response_format=MarketAnalysis)
|
||||
if result.pydantic:
|
||||
print("result", result.pydantic)
|
||||
else:
|
||||
print("result", result)
|
||||
|
||||
# Retorna a análise para atualizar o estado
|
||||
return {"analysis": result.pydantic}
|
||||
|
||||
@listen(analyze_market)
|
||||
def present_results(self, analysis) -> None:
|
||||
print("\nMarket Analysis Results")
|
||||
print("=====================")
|
||||
|
||||
if isinstance(analysis, dict):
|
||||
# Se recebemos um dict com a chave 'analysis', extrai o objeto de análise real
|
||||
market_analysis = analysis.get("analysis")
|
||||
else:
|
||||
market_analysis = analysis
|
||||
|
||||
if market_analysis and isinstance(market_analysis, MarketAnalysis):
|
||||
print("\nKey Market Trends:")
|
||||
for trend in market_analysis.key_trends:
|
||||
print(f"- {trend}")
|
||||
|
||||
print(f"\nMarket Size: {market_analysis.market_size}")
|
||||
|
||||
print("\nMajor Competitors:")
|
||||
for competitor in market_analysis.competitors:
|
||||
print(f"- {competitor}")
|
||||
else:
|
||||
print("No structured analysis data available.")
|
||||
print("Raw analysis:", analysis)
|
||||
|
||||
|
||||
# Exemplo de uso
|
||||
async def run_flow():
|
||||
flow = MarketResearchFlow()
|
||||
flow.plot("MarketResearchFlowPlot")
|
||||
result = await flow.kickoff_async(inputs={"product": "AI-powered chatbots"})
|
||||
return result
|
||||
|
||||
|
||||
# Executa o flow
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_flow())
|
||||
```
|
||||
|
||||

|
||||
@@ -463,7 +816,50 @@ No arquivo `main.py`, você cria seu flow e conecta as crews. É possível defin
|
||||
Veja um exemplo de como conectar a `poem_crew` no arquivo `main.py`:
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
#!/usr/bin/env python
|
||||
from random import randint
|
||||
|
||||
from pydantic import BaseModel
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from .crews.poem_crew.poem_crew import PoemCrew
|
||||
|
||||
class PoemState(BaseModel):
|
||||
sentence_count: int = 1
|
||||
poem: str = ""
|
||||
|
||||
class PoemFlow(Flow[PoemState]):
|
||||
|
||||
@start()
|
||||
def generate_sentence_count(self):
|
||||
print("Generating sentence count")
|
||||
self.state.sentence_count = randint(1, 5)
|
||||
|
||||
@listen(generate_sentence_count)
|
||||
def generate_poem(self):
|
||||
print("Generating poem")
|
||||
result = PoemCrew().crew().kickoff(inputs={"sentence_count": self.state.sentence_count})
|
||||
|
||||
print("Poem generated", result.raw)
|
||||
self.state.poem = result.raw
|
||||
|
||||
@listen(generate_poem)
|
||||
def save_poem(self):
|
||||
print("Saving poem")
|
||||
with open("poem.txt", "w") as f:
|
||||
f.write(self.state.poem)
|
||||
|
||||
def kickoff():
|
||||
poem_flow = PoemFlow()
|
||||
poem_flow.kickoff()
|
||||
|
||||
|
||||
def plot():
|
||||
poem_flow = PoemFlow()
|
||||
poem_flow.plot("PoemFlowPlot")
|
||||
|
||||
if __name__ == "__main__":
|
||||
kickoff()
|
||||
plot()
|
||||
```
|
||||
|
||||
Neste exemplo, a classe `PoemFlow` define um fluxo que gera a quantidade de frases, usa a `PoemCrew` para gerar um poema e, depois, salva o poema em um arquivo. O flow inicia com o método `kickoff()`, e o gráfico é gerado pelo método `plot()`.
|
||||
@@ -515,7 +911,8 @@ O CrewAI oferece duas formas práticas de gerar plots dos seus flows:
|
||||
Se estiver trabalhando diretamente com uma instância do flow, basta chamar o método `plot()` do objeto. Isso criará um arquivo HTML com o plot interativo do seu flow.
|
||||
|
||||
```python Code
|
||||
# (O código não é traduzido)
|
||||
# Considerando que você já tem uma instância do flow
|
||||
flow.plot("my_flow_plot")
|
||||
```
|
||||
|
||||
Esse comando gera um arquivo chamado `my_flow_plot.html` no diretório atual. Abra esse arquivo em um navegador para visualizar o plot interativo.
|
||||
|
||||
@@ -146,7 +146,6 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Responder Perguntas">
|
||||
Responda às perguntas de esclarecimento do Crew Assistant para refinar seus
|
||||
requisitos.
|
||||
@@ -161,12 +160,10 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
|
||||
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Aprovar ou Modificar">
|
||||
Aprove o plano ou solicite alterações, se necessário.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Baixar ou Fazer Deploy">
|
||||
Baixe o código para personalização ou faça o deploy diretamente na plataforma.
|
||||
</Step>
|
||||
|
||||
@@ -63,7 +63,60 @@ Com estado não estruturado:
|
||||
Veja um exemplo simples de gerenciamento de estado não estruturado:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class UnstructuredStateFlow(Flow):
|
||||
@start()
|
||||
def initialize_data(self):
|
||||
print("Initializing flow data")
|
||||
# Adiciona pares chave-valor ao estado
|
||||
self.state["user_name"] = "Alex"
|
||||
self.state["preferences"] = {
|
||||
"theme": "dark",
|
||||
"language": "English"
|
||||
}
|
||||
self.state["items"] = []
|
||||
|
||||
# O estado do flow recebe automaticamente um ID único
|
||||
print(f"Flow ID: {self.state['id']}")
|
||||
|
||||
return "Initialized"
|
||||
|
||||
@listen(initialize_data)
|
||||
def process_data(self, previous_result):
|
||||
print(f"Previous step returned: {previous_result}")
|
||||
|
||||
# Acessa e modifica o estado
|
||||
user = self.state["user_name"]
|
||||
print(f"Processing data for {user}")
|
||||
|
||||
# Adiciona itens a uma lista no estado
|
||||
self.state["items"].append("item1")
|
||||
self.state["items"].append("item2")
|
||||
|
||||
# Adiciona um novo par chave-valor
|
||||
self.state["processed"] = True
|
||||
|
||||
return "Processed"
|
||||
|
||||
@listen(process_data)
|
||||
def generate_summary(self, previous_result):
|
||||
# Acessa múltiplos valores do estado
|
||||
user = self.state["user_name"]
|
||||
theme = self.state["preferences"]["theme"]
|
||||
items = self.state["items"]
|
||||
processed = self.state.get("processed", False)
|
||||
|
||||
summary = f"User {user} has {len(items)} items with {theme} theme. "
|
||||
summary += "Data is processed." if processed else "Data is not processed."
|
||||
|
||||
return summary
|
||||
|
||||
# Executa o flow
|
||||
flow = UnstructuredStateFlow()
|
||||
result = flow.kickoff()
|
||||
print(f"Final result: {result}")
|
||||
print(f"Final state: {flow.state}")
|
||||
```
|
||||
|
||||
### Quando Usar Estado Não Estruturado
|
||||
@@ -94,7 +147,63 @@ Ao utilizar estado estruturado:
|
||||
Veja como implementar o gerenciamento de estado estruturado:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
# Define o modelo de estado
|
||||
class UserPreferences(BaseModel):
|
||||
theme: str = "light"
|
||||
language: str = "English"
|
||||
|
||||
class AppState(BaseModel):
|
||||
user_name: str = ""
|
||||
preferences: UserPreferences = UserPreferences()
|
||||
items: List[str] = []
|
||||
processed: bool = False
|
||||
completion_percentage: float = 0.0
|
||||
|
||||
# Cria um flow com estado tipado
|
||||
class StructuredStateFlow(Flow[AppState]):
|
||||
@start()
|
||||
def initialize_data(self):
|
||||
print("Initializing flow data")
|
||||
# Define valores do estado (com checagem de tipo)
|
||||
self.state.user_name = "Taylor"
|
||||
self.state.preferences.theme = "dark"
|
||||
|
||||
# O campo ID está disponível automaticamente
|
||||
print(f"Flow ID: {self.state.id}")
|
||||
|
||||
return "Initialized"
|
||||
|
||||
@listen(initialize_data)
|
||||
def process_data(self, previous_result):
|
||||
print(f"Processing data for {self.state.user_name}")
|
||||
|
||||
# Modifica o estado (com checagem de tipo)
|
||||
self.state.items.append("item1")
|
||||
self.state.items.append("item2")
|
||||
self.state.processed = True
|
||||
self.state.completion_percentage = 50.0
|
||||
|
||||
return "Processed"
|
||||
|
||||
@listen(process_data)
|
||||
def generate_summary(self, previous_result):
|
||||
# Acessa o estado (com autocompletar)
|
||||
summary = f"User {self.state.user_name} has {len(self.state.items)} items "
|
||||
summary += f"with {self.state.preferences.theme} theme. "
|
||||
summary += "Data is processed." if self.state.processed else "Data is not processed."
|
||||
summary += f" Completion: {self.state.completion_percentage}%"
|
||||
|
||||
return summary
|
||||
|
||||
# Executa o flow
|
||||
flow = StructuredStateFlow()
|
||||
result = flow.kickoff()
|
||||
print(f"Final result: {result}")
|
||||
print(f"Final state: {flow.state}")
|
||||
```
|
||||
|
||||
### Benefícios do Estado Estruturado
|
||||
@@ -138,7 +247,29 @@ Independente de você usar estado estruturado ou não estruturado, é possível
|
||||
Métodos do flow podem retornar valores que serão passados como argumento para métodos listeners:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
|
||||
class DataPassingFlow(Flow):
|
||||
@start()
|
||||
def generate_data(self):
|
||||
# Este valor de retorno será passado para os métodos listeners
|
||||
return "Generated data"
|
||||
|
||||
@listen(generate_data)
|
||||
def process_data(self, data_from_previous_step):
|
||||
print(f"Received: {data_from_previous_step}")
|
||||
# Você pode modificar os dados e repassá-los adiante
|
||||
processed_data = f"{data_from_previous_step} - processed"
|
||||
# Também atualiza o estado
|
||||
self.state["last_processed"] = processed_data
|
||||
return processed_data
|
||||
|
||||
@listen(process_data)
|
||||
def finalize_data(self, processed_data):
|
||||
print(f"Received processed data: {processed_data}")
|
||||
# Acessa tanto os dados passados quanto o estado
|
||||
last_processed = self.state.get("last_processed", "")
|
||||
return f"Final: {processed_data} (from state: {last_processed})"
|
||||
```
|
||||
|
||||
Esse padrão permite combinar passagem de dados direta com atualizações de estado para obter máxima flexibilidade.
|
||||
@@ -156,7 +287,36 @@ O decorador `@persist()` automatiza a persistência de estado, salvando o estado
|
||||
Ao aplicar em nível de classe, `@persist()` salva o estado após cada execução de método:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from crewai.flow.persistence import persist
|
||||
from pydantic import BaseModel
|
||||
|
||||
class CounterState(BaseModel):
|
||||
value: int = 0
|
||||
|
||||
@persist() # Aplica à classe inteira do flow
|
||||
class PersistentCounterFlow(Flow[CounterState]):
|
||||
@start()
|
||||
def increment(self):
|
||||
self.state.value += 1
|
||||
print(f"Incremented to {self.state.value}")
|
||||
return self.state.value
|
||||
|
||||
@listen(increment)
|
||||
def double(self, value):
|
||||
self.state.value = value * 2
|
||||
print(f"Doubled to {self.state.value}")
|
||||
return self.state.value
|
||||
|
||||
# Primeira execução
|
||||
flow1 = PersistentCounterFlow()
|
||||
result1 = flow1.kickoff()
|
||||
print(f"First run result: {result1}")
|
||||
|
||||
# Segunda execução - passa o ID para carregar o estado persistido
|
||||
flow2 = PersistentCounterFlow()
|
||||
result2 = flow2.kickoff(inputs={"id": flow1.state.id})
|
||||
print(f"Second run result: {result2}") # Será maior devido ao estado persistido
|
||||
```
|
||||
|
||||
#### Persistência em Nível de Método
|
||||
@@ -164,7 +324,26 @@ Ao aplicar em nível de classe, `@persist()` salva o estado após cada execuçã
|
||||
Para mais controle, você pode aplicar `@persist()` em métodos específicos:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from crewai.flow.persistence import persist
|
||||
|
||||
class SelectivePersistFlow(Flow):
|
||||
@start()
|
||||
def first_step(self):
|
||||
self.state["count"] = 1
|
||||
return "First step"
|
||||
|
||||
@persist() # Persiste apenas após este método
|
||||
@listen(first_step)
|
||||
def important_step(self, prev_result):
|
||||
self.state["count"] += 1
|
||||
self.state["important_data"] = "This will be persisted"
|
||||
return "Important step completed"
|
||||
|
||||
@listen(important_step)
|
||||
def final_step(self, prev_result):
|
||||
self.state["count"] += 1
|
||||
return f"Complete with count {self.state['count']}"
|
||||
```
|
||||
|
||||
#### Forking de Estado Persistido
|
||||
@@ -216,7 +395,45 @@ Notas sobre o comportamento:
|
||||
Você pode usar o estado para implementar lógicas condicionais complexas em seus flows:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, router, start
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PaymentState(BaseModel):
|
||||
amount: float = 0.0
|
||||
is_approved: bool = False
|
||||
retry_count: int = 0
|
||||
|
||||
class PaymentFlow(Flow[PaymentState]):
|
||||
@start()
|
||||
def process_payment(self):
|
||||
# Simula o processamento do pagamento
|
||||
self.state.amount = 100.0
|
||||
self.state.is_approved = self.state.amount < 1000
|
||||
return "Payment processed"
|
||||
|
||||
@router(process_payment)
|
||||
def check_approval(self, previous_result):
|
||||
if self.state.is_approved:
|
||||
return "approved"
|
||||
elif self.state.retry_count < 3:
|
||||
return "retry"
|
||||
else:
|
||||
return "rejected"
|
||||
|
||||
@listen("approved")
|
||||
def handle_approval(self):
|
||||
return f"Payment of ${self.state.amount} approved!"
|
||||
|
||||
@listen("retry")
|
||||
def handle_retry(self):
|
||||
self.state.retry_count += 1
|
||||
print(f"Retrying payment (attempt {self.state.retry_count})...")
|
||||
# Aqui poderia ser implementada a lógica de retry
|
||||
return "Retry initiated"
|
||||
|
||||
@listen("rejected")
|
||||
def handle_rejection(self):
|
||||
return f"Payment of ${self.state.amount} rejected after {self.state.retry_count} retries."
|
||||
```
|
||||
|
||||
### Manipulações Complexas de Estado
|
||||
@@ -224,7 +441,60 @@ Você pode usar o estado para implementar lógicas condicionais complexas em seu
|
||||
Para transformar estados complexos, você pode criar métodos dedicados:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict
|
||||
|
||||
class UserData(BaseModel):
|
||||
name: str
|
||||
active: bool = True
|
||||
login_count: int = 0
|
||||
|
||||
class ComplexState(BaseModel):
|
||||
users: Dict[str, UserData] = {}
|
||||
active_user_count: int = 0
|
||||
|
||||
class TransformationFlow(Flow[ComplexState]):
|
||||
@start()
|
||||
def initialize(self):
|
||||
# Adiciona alguns usuários
|
||||
self.add_user("alice", "Alice")
|
||||
self.add_user("bob", "Bob")
|
||||
self.add_user("charlie", "Charlie")
|
||||
return "Initialized"
|
||||
|
||||
@listen(initialize)
|
||||
def process_users(self, _):
|
||||
# Incrementa contagens de login
|
||||
for user_id in self.state.users:
|
||||
self.increment_login(user_id)
|
||||
|
||||
# Desativa um usuário
|
||||
self.deactivate_user("bob")
|
||||
|
||||
# Atualiza a contagem de ativos
|
||||
self.update_active_count()
|
||||
|
||||
return f"Processed {len(self.state.users)} users"
|
||||
|
||||
# Métodos auxiliares para transformações de estado
|
||||
def add_user(self, user_id: str, name: str):
|
||||
self.state.users[user_id] = UserData(name=name)
|
||||
self.update_active_count()
|
||||
|
||||
def increment_login(self, user_id: str):
|
||||
if user_id in self.state.users:
|
||||
self.state.users[user_id].login_count += 1
|
||||
|
||||
def deactivate_user(self, user_id: str):
|
||||
if user_id in self.state.users:
|
||||
self.state.users[user_id].active = False
|
||||
self.update_active_count()
|
||||
|
||||
def update_active_count(self):
|
||||
self.state.active_user_count = sum(
|
||||
1 for user in self.state.users.values() if user.active
|
||||
)
|
||||
```
|
||||
|
||||
Esse padrão de criar métodos auxiliares mantém seus métodos de flow limpos, enquanto permite manipulações complexas de estado.
|
||||
@@ -238,7 +508,71 @@ Um dos padrões mais poderosos na CrewAI é combinar o gerenciamento de estado d
|
||||
Você pode usar o estado do flow para parametrizar crews:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
from crewai.flow.flow import Flow, listen, start
|
||||
from crewai import Agent, Crew, Process, Task
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ResearchState(BaseModel):
|
||||
topic: str = ""
|
||||
depth: str = "medium"
|
||||
results: str = ""
|
||||
|
||||
class ResearchFlow(Flow[ResearchState]):
|
||||
@start()
|
||||
def get_parameters(self):
|
||||
# Em uma aplicação real, isso pode vir da entrada do usuário
|
||||
self.state.topic = "Artificial Intelligence Ethics"
|
||||
self.state.depth = "deep"
|
||||
return "Parameters set"
|
||||
|
||||
@listen(get_parameters)
|
||||
def execute_research(self, _):
|
||||
# Cria os agentes
|
||||
researcher = Agent(
|
||||
role="Research Specialist",
|
||||
goal=f"Research {self.state.topic} in {self.state.depth} detail",
|
||||
backstory="You are an expert researcher with a talent for finding accurate information."
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
role="Content Writer",
|
||||
goal="Transform research into clear, engaging content",
|
||||
backstory="You excel at communicating complex ideas clearly and concisely."
|
||||
)
|
||||
|
||||
# Cria as tarefas
|
||||
research_task = Task(
|
||||
description=f"Research {self.state.topic} with {self.state.depth} analysis",
|
||||
expected_output="Comprehensive research notes in markdown format",
|
||||
agent=researcher
|
||||
)
|
||||
|
||||
writing_task = Task(
|
||||
description=f"Create a summary on {self.state.topic} based on the research",
|
||||
expected_output="Well-written article in markdown format",
|
||||
agent=writer,
|
||||
context=[research_task]
|
||||
)
|
||||
|
||||
# Cria e executa a crew
|
||||
research_crew = Crew(
|
||||
agents=[researcher, writer],
|
||||
tasks=[research_task, writing_task],
|
||||
process=Process.sequential,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Executa a crew e armazena o resultado no estado
|
||||
result = research_crew.kickoff()
|
||||
self.state.results = result.raw
|
||||
|
||||
return "Research completed"
|
||||
|
||||
@listen(execute_research)
|
||||
def summarize_results(self, _):
|
||||
# Acessa os resultados armazenados
|
||||
result_length = len(self.state.results)
|
||||
return f"Research on {self.state.topic} completed with {result_length} characters of results."
|
||||
```
|
||||
|
||||
### Manipulando Saídas de Crews no Estado
|
||||
@@ -246,7 +580,21 @@ Você pode usar o estado do flow para parametrizar crews:
|
||||
Quando um crew finaliza, é possível processar sua saída e armazená-la no estado do flow:
|
||||
|
||||
```python
|
||||
# código não traduzido
|
||||
@listen(execute_crew)
|
||||
def process_crew_results(self, _):
|
||||
# Faz parsing dos resultados brutos (assumindo saída em JSON)
|
||||
import json
|
||||
try:
|
||||
results_dict = json.loads(self.state.raw_results)
|
||||
self.state.processed_results = {
|
||||
"title": results_dict.get("title", ""),
|
||||
"main_points": results_dict.get("main_points", []),
|
||||
"conclusion": results_dict.get("conclusion", "")
|
||||
}
|
||||
return "Results processed successfully"
|
||||
except json.JSONDecodeError:
|
||||
self.state.error = "Failed to parse crew results as JSON"
|
||||
return "Error processing results"
|
||||
```
|
||||
|
||||
## Boas Práticas para Gerenciamento de Estado
|
||||
@@ -256,7 +604,19 @@ Quando um crew finaliza, é possível processar sua saída e armazená-la no est
|
||||
Projete seu estado para conter somente o necessário:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
# Abrangente demais
|
||||
class BloatedState(BaseModel):
|
||||
user_data: Dict = {}
|
||||
system_settings: Dict = {}
|
||||
temporary_calculations: List = []
|
||||
debug_info: Dict = {}
|
||||
# ...muitos outros campos
|
||||
|
||||
# Melhor: estado focado
|
||||
class FocusedState(BaseModel):
|
||||
user_id: str
|
||||
preferences: Dict[str, str]
|
||||
completion_status: Dict[str, bool]
|
||||
```
|
||||
|
||||
### 2. Use Estado Estruturado em Flows Complexos
|
||||
@@ -264,7 +624,23 @@ Projete seu estado para conter somente o necessário:
|
||||
À medida que seus flows evoluem em complexidade, o estado estruturado se torna cada vez mais valioso:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
# Flow simples pode usar estado não estruturado
|
||||
class SimpleGreetingFlow(Flow):
|
||||
@start()
|
||||
def greet(self):
|
||||
self.state["name"] = "World"
|
||||
return f"Hello, {self.state['name']}!"
|
||||
|
||||
# Flow complexo se beneficia de estado estruturado
|
||||
class UserRegistrationState(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
verification_status: bool = False
|
||||
registration_date: datetime = Field(default_factory=datetime.now)
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
class RegistrationFlow(Flow[UserRegistrationState]):
|
||||
# Métodos com acesso ao estado fortemente tipado
|
||||
```
|
||||
|
||||
### 3. Documente Transições de Estado
|
||||
@@ -272,7 +648,18 @@ Projete seu estado para conter somente o necessário:
|
||||
Para flows complexos, documente como o estado muda ao longo da execução:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
@start()
|
||||
def initialize_order(self):
|
||||
"""
|
||||
Initialize order state with empty values.
|
||||
|
||||
State before: {}
|
||||
State after: {order_id: str, items: [], status: 'new'}
|
||||
"""
|
||||
self.state.order_id = str(uuid.uuid4())
|
||||
self.state.items = []
|
||||
self.state.status = "new"
|
||||
return "Order initialized"
|
||||
```
|
||||
|
||||
### 4. Trate Erros de Estado de Forma Elegante
|
||||
@@ -280,7 +667,18 @@ Para flows complexos, documente como o estado muda ao longo da execução:
|
||||
Implemente tratamento de erros ao acessar o estado:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
@listen(previous_step)
|
||||
def process_data(self, _):
|
||||
try:
|
||||
# Tenta acessar um valor que pode não existir
|
||||
user_preference = self.state.preferences.get("theme", "default")
|
||||
except (AttributeError, KeyError):
|
||||
# Trata o erro de forma elegante
|
||||
self.state.errors = self.state.get("errors", [])
|
||||
self.state.errors.append("Failed to access preferences")
|
||||
user_preference = "default"
|
||||
|
||||
return f"Used preference: {user_preference}"
|
||||
```
|
||||
|
||||
### 5. Use o Estado Para Acompanhar o Progresso
|
||||
@@ -288,7 +686,30 @@ Implemente tratamento de erros ao acessar o estado:
|
||||
Aproveite o estado para monitorar o progresso em flows de longa duração:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
class ProgressTrackingFlow(Flow):
|
||||
@start()
|
||||
def initialize(self):
|
||||
self.state["total_steps"] = 3
|
||||
self.state["current_step"] = 0
|
||||
self.state["progress"] = 0.0
|
||||
self.update_progress()
|
||||
return "Initialized"
|
||||
|
||||
def update_progress(self):
|
||||
"""Helper method to calculate and update progress"""
|
||||
if self.state.get("total_steps", 0) > 0:
|
||||
self.state["progress"] = (self.state.get("current_step", 0) /
|
||||
self.state["total_steps"]) * 100
|
||||
print(f"Progress: {self.state['progress']:.1f}%")
|
||||
|
||||
@listen(initialize)
|
||||
def step_one(self, _):
|
||||
# Realiza o trabalho...
|
||||
self.state["current_step"] = 1
|
||||
self.update_progress()
|
||||
return "Step 1 complete"
|
||||
|
||||
# Etapas adicionais...
|
||||
```
|
||||
|
||||
### 6. Prefira Operações Imutáveis Quando Possível
|
||||
@@ -296,7 +717,22 @@ Aproveite o estado para monitorar o progresso em flows de longa duração:
|
||||
Especialmente com estado estruturado, prefira operações imutáveis para maior clareza:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
# Em vez de modificar listas no local:
|
||||
self.state.items.append(new_item) # Operação mutável
|
||||
|
||||
# Considere criar um novo estado:
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
class ItemState(BaseModel):
|
||||
items: List[str] = []
|
||||
|
||||
class ImmutableFlow(Flow[ItemState]):
|
||||
@start()
|
||||
def add_item(self):
|
||||
# Cria uma nova lista com o item adicionado
|
||||
self.state.items = [*self.state.items, "new item"]
|
||||
return "Item added"
|
||||
```
|
||||
|
||||
## Depurando o Estado do Flow
|
||||
@@ -306,7 +742,24 @@ Especialmente com estado estruturado, prefira operações imutáveis para maior
|
||||
Ao desenvolver, adicione logs para acompanhar mudanças no estado:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
class LoggingFlow(Flow):
|
||||
def log_state(self, step_name):
|
||||
logging.info(f"State after {step_name}: {self.state}")
|
||||
|
||||
@start()
|
||||
def initialize(self):
|
||||
self.state["counter"] = 0
|
||||
self.log_state("initialize")
|
||||
return "Initialized"
|
||||
|
||||
@listen(initialize)
|
||||
def increment(self, _):
|
||||
self.state["counter"] += 1
|
||||
self.log_state("increment")
|
||||
return f"Incremented to {self.state['counter']}"
|
||||
```
|
||||
|
||||
### Visualizando o Estado
|
||||
@@ -314,7 +767,30 @@ Ao desenvolver, adicione logs para acompanhar mudanças no estado:
|
||||
Você pode adicionar métodos para visualizar seu estado durante o debug:
|
||||
|
||||
```python
|
||||
# Exemplo não traduzido
|
||||
def visualize_state(self):
|
||||
"""Create a simple visualization of the current state"""
|
||||
import json
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
console = Console()
|
||||
|
||||
if hasattr(self.state, "model_dump"):
|
||||
# Pydantic v2
|
||||
state_dict = self.state.model_dump()
|
||||
elif hasattr(self.state, "dict"):
|
||||
# Pydantic v1
|
||||
state_dict = self.state.dict()
|
||||
else:
|
||||
# Estado não estruturado
|
||||
state_dict = dict(self.state)
|
||||
|
||||
# Remove o id para uma saída mais limpa
|
||||
if "id" in state_dict:
|
||||
state_dict.pop("id")
|
||||
|
||||
state_json = json.dumps(state_dict, indent=2, default=str)
|
||||
console.print(Panel(state_json, title="Current Flow State"))
|
||||
```
|
||||
|
||||
## Conclusão
|
||||
|
||||
@@ -797,7 +797,6 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
|
||||
Inicie com opções consagradas como **GPT-4.1**, **Claude 3.7 Sonnet** ou **Gemini 2.0 Flash**, que oferecem bom desempenho e ampla validação.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Identifique Demandas Especializadas">
|
||||
Descubra se sua crew possui requisitos específicos (código, raciocínio,
|
||||
velocidade) que justifiquem modelos como **Claude 4 Sonnet** para
|
||||
@@ -805,7 +804,6 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
|
||||
velocidade, considere Groq aliado à seleção do modelo.
|
||||
</Step>
|
||||
|
||||
{" "}
|
||||
<Step title="Implemente Estratégia Multi-Modelo">
|
||||
Use modelos diferentes para agentes distintos conforme o papel. Modelos de
|
||||
alta capacidade para managers e tarefas complexas, eficientes para rotinas.
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5a5",
|
||||
"crewai-core==1.14.5",
|
||||
"click~=8.1.7",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
@@ -26,6 +26,7 @@ from crewai_cli.replay_from_task import replay_task_command
|
||||
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.skills.main import SkillCommand
|
||||
from crewai_cli.task_outputs import load_task_outputs
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
from crewai_cli.train_crew import train_crew
|
||||
@@ -546,6 +547,56 @@ def tool_publish(is_public: bool, force: bool) -> None:
|
||||
tool_cmd.publish(is_public, force)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def skill() -> None:
|
||||
"""Skill Repository related commands."""
|
||||
|
||||
|
||||
@skill.command(name="create")
|
||||
@click.argument("name")
|
||||
@click.option(
|
||||
"--no-project",
|
||||
"in_project",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
flag_value=False,
|
||||
help="Create skill in current dir instead of ./skills/",
|
||||
)
|
||||
def skill_create(name: str, in_project: bool) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.create(name, in_project=in_project)
|
||||
|
||||
|
||||
@skill.command(name="install")
|
||||
@click.argument("ref")
|
||||
def skill_install(ref: str) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.install(ref)
|
||||
|
||||
|
||||
@skill.command(name="publish")
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
show_default=True,
|
||||
help="Skip git-state validation.",
|
||||
)
|
||||
@click.option("--public", "is_public", flag_value=True, default=False)
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
|
||||
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.publish(is_public, org=org, force=force)
|
||||
|
||||
|
||||
@skill.command(name="list")
|
||||
def skill_list() -> None:
|
||||
"""List locally installed skills."""
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.list_cached()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def template() -> None:
|
||||
"""Browse and install project templates."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from crewai_core.plus_api import CreateCrewPayload
|
||||
from rich.console import Console
|
||||
|
||||
from crewai_cli import git
|
||||
@@ -161,7 +162,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
self,
|
||||
env_vars: dict[str, str],
|
||||
remote_repo_url: str,
|
||||
) -> dict[str, Any]:
|
||||
) -> CreateCrewPayload:
|
||||
"""
|
||||
Create the payload for crew creation.
|
||||
|
||||
@@ -172,6 +173,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
|
||||
Returns:
|
||||
Dict[str, Any]: The payload for crew creation.
|
||||
"""
|
||||
if not self.project_name:
|
||||
raise ValueError("project_name is required to create a deployment payload")
|
||||
return {
|
||||
"deploy": {
|
||||
"name": self.project_name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from functools import lru_cache
|
||||
from functools import cached_property
|
||||
import subprocess
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class Repository:
|
||||
if not self.is_git_installed():
|
||||
raise ValueError("Git is not installed or not found in your PATH.")
|
||||
|
||||
if not self.is_git_repo():
|
||||
if not self.is_git_repo:
|
||||
raise ValueError(f"{self.path} is not a Git repository.")
|
||||
|
||||
self.fetch()
|
||||
@@ -40,13 +40,9 @@ class Repository:
|
||||
encoding="utf-8",
|
||||
).strip()
|
||||
|
||||
@lru_cache(maxsize=None) # noqa: B019
|
||||
@cached_property
|
||||
def is_git_repo(self) -> bool:
|
||||
"""Check if the current directory is a git repository.
|
||||
|
||||
Notes:
|
||||
- TODO: This method is cached to avoid redundant checks, but using lru_cache on methods can lead to memory leaks
|
||||
"""
|
||||
"""Check if the current directory is a git repository."""
|
||||
try:
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
|
||||
|
||||
0
lib/cli/src/crewai_cli/skills/__init__.py
Normal file
0
lib/cli/src/crewai_cli/skills/__init__.py
Normal file
415
lib/cli/src/crewai_cli/skills/main.py
Normal file
415
lib/cli/src/crewai_cli/skills/main.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Skill Repository CLI commands for CrewAI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from crewai_cli.command import BaseCommand, PlusAPIMixin
|
||||
from crewai_cli.config import Settings
|
||||
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
_SKILL_MD_TEMPLATE = """\
|
||||
---
|
||||
name: {name}
|
||||
version: 0.1.0
|
||||
description: |
|
||||
A short description of what this skill does.
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Describe the skill behaviour here. This section is shown to the agent at activation time.
|
||||
"""
|
||||
|
||||
|
||||
class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
"""Skill Repository related operations for CrewAI projects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
BaseCommand.__init__(self)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# create
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create(self, name: str, in_project: bool = True) -> None:
|
||||
"""Scaffold a new skill directory.
|
||||
|
||||
If pyproject.toml is present (crew project), creates ./skills/{name}/.
|
||||
Otherwise creates ./{name}/.
|
||||
"""
|
||||
if in_project and os.path.isfile("pyproject.toml"):
|
||||
skill_dir = Path("skills") / name
|
||||
else:
|
||||
skill_dir = Path(name)
|
||||
|
||||
if skill_dir.exists():
|
||||
console.print(f"[red]Directory {skill_dir} already exists.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "scripts").mkdir()
|
||||
(skill_dir / "references").mkdir()
|
||||
(skill_dir / "assets").mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name))
|
||||
|
||||
console.print(
|
||||
f"[green]Created skill [bold]{name}[/bold] at [bold]{skill_dir}[/bold].[/green]"
|
||||
)
|
||||
console.print(f"Edit [bold]{skill_md}[/bold] to define the skill instructions.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# install
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install(self, ref: str) -> None:
|
||||
"""Download and install a registry skill.
|
||||
|
||||
Format: @org/name
|
||||
|
||||
Inside a crew project (pyproject.toml present): installs to ./skills/{name}/
|
||||
Outside a project: installs to ~/.crewai/skills/{org}/{name}/
|
||||
"""
|
||||
if not ref.startswith("@"):
|
||||
console.print(
|
||||
"[red]Invalid skill reference. Use the format @org/name.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
without_at = ref[1:]
|
||||
if without_at.count("/") != 1:
|
||||
console.print(
|
||||
"[red]Invalid skill reference. Use the format @org/name.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
org, name = without_at.split("/", 1)
|
||||
if (
|
||||
not org
|
||||
or not name
|
||||
or org.startswith(".")
|
||||
or name.startswith(".")
|
||||
or len(Path(org).parts) != 1
|
||||
or len(Path(name).parts) != 1
|
||||
):
|
||||
console.print(
|
||||
"[red]Invalid skill reference: org and name must be single, "
|
||||
"non-empty path segments (no slashes, no '..').[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self._print_current_organization()
|
||||
console.print(f"[bold blue]Downloading skill {ref}...[/bold blue]")
|
||||
|
||||
get_response = self.plus_api_client.get_skill(org, name)
|
||||
|
||||
if get_response.status_code == 404:
|
||||
console.print(
|
||||
f"[red]Skill {ref} not found. Ensure it has been published and you have access.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if get_response.status_code != 200:
|
||||
console.print(
|
||||
f"[red]Failed to download skill {ref}: {get_response.status_code}[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
data = get_response.json()
|
||||
version = data.get("latest_version") or data.get("version")
|
||||
|
||||
download_url = data.get("download_url")
|
||||
if download_url:
|
||||
import httpx
|
||||
|
||||
dl_response = httpx.get(download_url, follow_redirects=True)
|
||||
dl_response.raise_for_status()
|
||||
archive_bytes = dl_response.content
|
||||
else:
|
||||
encoded = data.get("file", "")
|
||||
if "," in encoded:
|
||||
encoded = encoded.split(",", 1)[1]
|
||||
archive_bytes = base64.b64decode(encoded)
|
||||
|
||||
in_project = os.path.isfile("pyproject.toml")
|
||||
if in_project:
|
||||
dest = Path("skills") / name
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
self._unpack_archive(archive_bytes, dest)
|
||||
console.print(
|
||||
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to [bold]{dest}[/bold].[/green]"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
cache = SkillCacheManager()
|
||||
cache.store(org, name, version, archive_bytes)
|
||||
except ImportError:
|
||||
# Fallback if SDK not installed — write directly
|
||||
cache_dir = Path.home() / ".crewai" / "skills" / org / name
|
||||
if cache_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._unpack_archive(archive_bytes, cache_dir)
|
||||
# Write metadata so `crewai skill list` can discover it
|
||||
from datetime import datetime, timezone
|
||||
|
||||
meta = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
(cache_dir / ".crewai_meta.json").write_text(json.dumps(meta, indent=2))
|
||||
console.print(
|
||||
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to global cache.[/green]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# publish
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def publish(self, is_public: bool, org: str | None, force: bool = False) -> None:
|
||||
"""Publish the skill in the current directory to the registry."""
|
||||
skill_md = Path("SKILL.md")
|
||||
if not skill_md.exists():
|
||||
console.print(
|
||||
"[red]No SKILL.md found in current directory. "
|
||||
"Run this command from inside a skill directory.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Parse frontmatter to extract name + version
|
||||
try:
|
||||
frontmatter = self._parse_frontmatter(skill_md.read_text())
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Failed to parse SKILL.md frontmatter: {exc}[/red]")
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
name = frontmatter.get("name")
|
||||
version = frontmatter.get("version")
|
||||
description = frontmatter.get("description")
|
||||
|
||||
if not name:
|
||||
console.print(
|
||||
"[red]SKILL.md frontmatter must include a 'name' field.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not version:
|
||||
console.print(
|
||||
"[red]SKILL.md frontmatter must include a 'version' field before publishing.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
settings = Settings()
|
||||
effective_org = org or settings.org_name
|
||||
if not effective_org:
|
||||
console.print(
|
||||
"[red]No organisation set. Run `crewai org switch <org_id>` first, "
|
||||
"or pass --org.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self._print_current_organization()
|
||||
console.print(
|
||||
f"[bold blue]Publishing skill [bold]{name}[/bold] v{version} to {effective_org}...[/bold blue]"
|
||||
)
|
||||
|
||||
archive_bytes = self._build_skill_tarball()
|
||||
encoded_file = "data:application/x-gzip;base64," + base64.b64encode(
|
||||
archive_bytes
|
||||
).decode("utf-8")
|
||||
|
||||
response = self.plus_api_client.publish_skill(
|
||||
org=effective_org,
|
||||
name=name,
|
||||
version=version,
|
||||
is_public=is_public,
|
||||
description=description,
|
||||
encoded_file=encoded_file,
|
||||
)
|
||||
|
||||
self._validate_response(response)
|
||||
|
||||
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
console.print(
|
||||
f"[green]Published [bold]{effective_org}/{name}[/bold] v{version}.\n\n"
|
||||
"Security checks are running in the background. "
|
||||
"Your skill will be available once checks complete.\n"
|
||||
f"Monitor status at: {base_url}/crewai_plus/skills/{effective_org}/{name}[/green]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# list_cached
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_cached(self) -> None:
|
||||
"""Show locally installed skills."""
|
||||
table = Table(title="Installed Skills", show_lines=True)
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Ref")
|
||||
table.add_column("Version")
|
||||
table.add_column("Path")
|
||||
|
||||
# Project-local ./skills/
|
||||
local_skills_dir = Path("skills")
|
||||
if local_skills_dir.is_dir():
|
||||
for skill_dir in sorted(local_skills_dir.iterdir()):
|
||||
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||
version = self._read_version(skill_dir / "SKILL.md")
|
||||
table.add_row(
|
||||
"project",
|
||||
skill_dir.name,
|
||||
version or "-",
|
||||
str(skill_dir),
|
||||
)
|
||||
|
||||
# Global cache
|
||||
cache_root = Path.home() / ".crewai" / "skills"
|
||||
if cache_root.exists():
|
||||
for org_dir in sorted(cache_root.iterdir()):
|
||||
if not org_dir.is_dir():
|
||||
continue
|
||||
for skill_dir in sorted(org_dir.iterdir()):
|
||||
meta_file = skill_dir / ".crewai_meta.json"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
table.add_row(
|
||||
"cache",
|
||||
f"@{meta['org']}/{meta['name']}",
|
||||
meta.get("version") or "-",
|
||||
str(skill_dir),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
console.print(
|
||||
f"[yellow]Warning: skipping malformed cache entry at {meta_file}[/yellow]"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _print_current_organization(self) -> None:
|
||||
settings = Settings()
|
||||
if settings.org_uuid:
|
||||
console.print(
|
||||
f"Current organization: {settings.org_name} ({settings.org_uuid})",
|
||||
style="bold blue",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"No organization currently set. We recommend setting one before using: "
|
||||
"`crewai org switch <org_id>` command.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
def _unpack_archive(self, archive_bytes: bytes, dest: Path) -> None:
|
||||
"""Unpack a .tar.gz or .zip archive into dest."""
|
||||
# Try tar first, then zip
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf:
|
||||
try:
|
||||
tf.extractall(dest, filter="data")
|
||||
except TypeError:
|
||||
_safe_extractall(tf, dest)
|
||||
return
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
|
||||
# Fallback: zip
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
|
||||
_safe_extract_zip(zf, dest)
|
||||
|
||||
def _build_skill_tarball(self) -> bytes:
|
||||
"""Build an in-memory .tar.gz of SKILL.md + scripts/ + references/ + assets/."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
tf.add("SKILL.md")
|
||||
for folder in ("scripts", "references", "assets"):
|
||||
folder_path = Path(folder)
|
||||
if folder_path.is_dir():
|
||||
for fpath in sorted(folder_path.rglob("*")):
|
||||
if fpath.is_file():
|
||||
tf.add(str(fpath))
|
||||
return buf.getvalue()
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> dict[str, str]:
|
||||
"""Extract YAML frontmatter fields from a SKILL.md string.
|
||||
|
||||
Reuses crewai.skills.parser when available, with a minimal
|
||||
fallback for environments where the full SDK isn't installed.
|
||||
"""
|
||||
try:
|
||||
from crewai.skills.parser import parse_frontmatter
|
||||
|
||||
fm_dict, _ = parse_frontmatter(content)
|
||||
return fm_dict
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: minimal YAML parsing without SDK dependency
|
||||
import re
|
||||
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("No YAML frontmatter block found")
|
||||
try:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_load(match.group(1)) or {}
|
||||
except ImportError:
|
||||
result: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
result[key.strip()] = value.strip()
|
||||
return result
|
||||
|
||||
def _read_version(self, skill_md: Path) -> str | None:
|
||||
"""Read the version field from a SKILL.md file, or None."""
|
||||
try:
|
||||
fm = self._parse_frontmatter(skill_md.read_text())
|
||||
return fm.get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe ZIP extraction."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (dest / member).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member!r}")
|
||||
zf.extractall(dest) # noqa: S202
|
||||
0
lib/cli/tests/skills/__init__.py
Normal file
0
lib/cli/tests/skills/__init__.py
Normal file
205
lib/cli/tests/skills/test_main.py
Normal file
205
lib/cli/tests/skills/test_main.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for SkillCommand CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.shared.token_manager import TokenManager
|
||||
|
||||
|
||||
@contextmanager
|
||||
def in_temp_dir():
|
||||
original = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
os.chdir(td)
|
||||
try:
|
||||
yield td
|
||||
finally:
|
||||
os.chdir(original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_command():
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(
|
||||
TokenManager, "_get_secure_storage_path", return_value=Path(temp_dir)
|
||||
):
|
||||
TokenManager().save_tokens(
|
||||
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
|
||||
)
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
cmd = SkillCommand()
|
||||
yield cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillCreate:
|
||||
def test_create_in_project(self, skill_command, tmp_path):
|
||||
with in_temp_dir():
|
||||
# Simulate being inside a project
|
||||
Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n")
|
||||
skill_command.create("my-skill")
|
||||
assert Path("skills/my-skill/SKILL.md").exists()
|
||||
assert Path("skills/my-skill/scripts").is_dir()
|
||||
assert Path("skills/my-skill/references").is_dir()
|
||||
assert Path("skills/my-skill/assets").is_dir()
|
||||
|
||||
def test_create_outside_project(self, skill_command, tmp_path):
|
||||
with in_temp_dir():
|
||||
skill_command.create("standalone-skill", in_project=False)
|
||||
assert Path("standalone-skill/SKILL.md").exists()
|
||||
|
||||
def test_create_adds_name_to_skill_md(self, skill_command):
|
||||
with in_temp_dir():
|
||||
skill_command.create("hello-world", in_project=False)
|
||||
content = Path("hello-world/SKILL.md").read_text()
|
||||
assert "name: hello-world" in content
|
||||
assert "version: 0.1.0" in content
|
||||
|
||||
def test_create_fails_if_dir_exists(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("existing-skill").mkdir()
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.create("existing-skill", in_project=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillInstall:
|
||||
def _zip_skill(self, name: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("SKILL.md", f"---\nname: {name}\ndescription: Test.\n---\nInstructions.")
|
||||
return buf.getvalue()
|
||||
|
||||
def test_install_invalid_ref_no_at(self, skill_command):
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("acme/my-skill")
|
||||
|
||||
def test_install_invalid_ref_no_slash(self, skill_command):
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("@acmeskill")
|
||||
|
||||
def test_install_404(self, skill_command):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("@acme/ghost")
|
||||
|
||||
def test_install_in_project(self, skill_command):
|
||||
import base64
|
||||
archive = self._zip_skill("my-skill")
|
||||
encoded = "data:application/zip;base64," + base64.b64encode(archive).decode()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"file": encoded, "version": "1.0.0"}
|
||||
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
||||
|
||||
with in_temp_dir():
|
||||
Path("pyproject.toml").write_text("[tool]\n")
|
||||
skill_command.install("@acme/my-skill")
|
||||
assert Path("skills/my-skill/SKILL.md").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# publish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillPublish:
|
||||
def test_publish_no_skill_md(self, skill_command):
|
||||
with in_temp_dir():
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_missing_version(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test.\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_missing_name(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\ndescription: Test.\nversion: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_no_org(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 1.0.0\ndescription: Test.\n---\nInstructions."
|
||||
)
|
||||
with patch.object(skill_command, "plus_api_client") as mock_client:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = True
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client.publish_skill.return_value = mock_resp
|
||||
# No org set → should SystemExit (no org_name in settings)
|
||||
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
||||
mock_settings_cls.return_value.org_name = None
|
||||
mock_settings_cls.return_value.enterprise_base_url = None
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org=None)
|
||||
|
||||
def test_publish_calls_api(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 1.0.0\ndescription: A test skill.\n---\nInstructions."
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = True
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
|
||||
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
||||
mock_settings_cls.return_value.org_name = "acme"
|
||||
mock_settings_cls.return_value.enterprise_base_url = None
|
||||
|
||||
skill_command.publish(is_public=False, org="acme")
|
||||
|
||||
skill_command.plus_api_client.publish_skill.assert_called_once()
|
||||
call_kwargs = skill_command.plus_api_client.publish_skill.call_args
|
||||
assert call_kwargs.kwargs["name"] == "my-skill"
|
||||
assert call_kwargs.kwargs["version"] == "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_cached
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillListCached:
|
||||
def test_list_cached_empty(self, skill_command, capsys):
|
||||
with in_temp_dir():
|
||||
skill_command.list_cached()
|
||||
# Should not raise
|
||||
|
||||
def test_list_cached_shows_project_skills(self, skill_command, capsys):
|
||||
with in_temp_dir():
|
||||
skill_dir = Path("skills/my-skill")
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 0.5.0\ndescription: A skill.\n---\nBody."
|
||||
)
|
||||
skill_command.list_cached()
|
||||
# Should complete without error
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
@@ -3,36 +3,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
from crewai_core.settings import Settings
|
||||
from crewai_core.version import get_crewai_version
|
||||
|
||||
|
||||
HttpMethod = Literal["GET", "POST", "PATCH", "DELETE"]
|
||||
|
||||
|
||||
class AvailableExport(TypedDict):
|
||||
name: str
|
||||
|
||||
|
||||
class EnvVarEntry(TypedDict):
|
||||
name: str
|
||||
description: str
|
||||
required: bool
|
||||
default: str | None
|
||||
|
||||
|
||||
class ToolMetadata(TypedDict):
|
||||
name: str
|
||||
module: str
|
||||
humanized_name: str
|
||||
description: str
|
||||
run_params_schema: dict[str, Any]
|
||||
init_params_schema: dict[str, Any]
|
||||
env_vars: list[EnvVarEntry]
|
||||
|
||||
|
||||
class ToolsMetadataPayload(TypedDict):
|
||||
package: str
|
||||
tools: list[ToolMetadata] | None
|
||||
|
||||
|
||||
class PublishToolPayload(TypedDict):
|
||||
handle: str
|
||||
public: bool
|
||||
version: str
|
||||
file: str
|
||||
description: str | None
|
||||
available_exports: list[AvailableExport] | None
|
||||
tools_metadata: ToolsMetadataPayload | None
|
||||
|
||||
|
||||
class CrewDeploymentSpec(TypedDict):
|
||||
name: str
|
||||
repo_clone_url: str
|
||||
env: dict[str, str]
|
||||
|
||||
|
||||
class CreateCrewPayload(TypedDict):
|
||||
deploy: CrewDeploymentSpec
|
||||
|
||||
|
||||
class _WithUserIdentifier(TypedDict):
|
||||
user_identifier: NotRequired[str]
|
||||
|
||||
|
||||
class LoginPayload(_WithUserIdentifier):
|
||||
pass
|
||||
|
||||
|
||||
class TraceExecutionContext(TypedDict):
|
||||
crew_fingerprint: str | None
|
||||
crew_name: str | None
|
||||
flow_name: str | None
|
||||
crewai_version: str
|
||||
privacy_level: str
|
||||
|
||||
|
||||
class TraceExecutionMetadata(TypedDict):
|
||||
expected_duration_estimate: int
|
||||
agent_count: int
|
||||
task_count: int
|
||||
flow_method_count: int
|
||||
execution_started_at: str
|
||||
|
||||
|
||||
class TraceBatchInitPayload(_WithUserIdentifier):
|
||||
trace_id: str
|
||||
execution_type: str
|
||||
execution_context: TraceExecutionContext
|
||||
execution_metadata: TraceExecutionMetadata
|
||||
ephemeral_trace_id: NotRequired[str]
|
||||
|
||||
|
||||
class TraceBatchMetadata(TypedDict):
|
||||
events_count: int
|
||||
batch_sequence: int
|
||||
is_final_batch: bool
|
||||
|
||||
|
||||
class TraceEventsPayload(TypedDict):
|
||||
events: list[dict[str, Any]]
|
||||
batch_metadata: TraceBatchMetadata
|
||||
|
||||
|
||||
class TraceFinalizePayload(TypedDict):
|
||||
status: Literal["completed"]
|
||||
duration_ms: float | None
|
||||
final_event_count: int
|
||||
|
||||
|
||||
class TraceFailedPayload(TypedDict):
|
||||
status: Literal["failed"]
|
||||
failure_reason: str
|
||||
|
||||
|
||||
Headers = TypedDict(
|
||||
"Headers",
|
||||
{
|
||||
"Content-Type": str,
|
||||
"User-Agent": str,
|
||||
"X-Crewai-Version": str,
|
||||
"Authorization": NotRequired[str],
|
||||
"X-Crewai-Organization-Id": NotRequired[str],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class RequestKwargs(TypedDict):
|
||||
headers: dict[str, str]
|
||||
json: NotRequired[Any]
|
||||
params: NotRequired[dict[str, str]]
|
||||
timeout: NotRequired[float]
|
||||
|
||||
|
||||
class PlusAPI:
|
||||
"""Client for working with the CrewAI+ API."""
|
||||
|
||||
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
|
||||
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
|
||||
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
|
||||
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
|
||||
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
|
||||
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
|
||||
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
|
||||
TOOLS_RESOURCE: Final = "/crewai_plus/api/v1/tools"
|
||||
SKILLS_RESOURCE: Final = "/crewai_plus/api/v1/skills"
|
||||
ORGANIZATIONS_RESOURCE: Final = "/crewai_plus/api/v1/me/organizations"
|
||||
CREWS_RESOURCE: Final = "/crewai_plus/api/v1/crews"
|
||||
AGENTS_RESOURCE: Final = "/crewai_plus/api/v1/agents"
|
||||
TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing"
|
||||
EPHEMERAL_TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing/ephemeral"
|
||||
INTEGRATIONS_RESOURCE: Final = "/crewai_plus/api/v1/integrations"
|
||||
|
||||
def __init__(self, api_key: str | None = None) -> None:
|
||||
version = get_crewai_version()
|
||||
self.api_key = api_key
|
||||
self.headers = {
|
||||
self.headers: Headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
|
||||
"X-Crewai-Version": get_crewai_version(),
|
||||
"User-Agent": f"CrewAI-CLI/{version}",
|
||||
"X-Crewai-Version": version,
|
||||
}
|
||||
if api_key:
|
||||
self.headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
settings = Settings()
|
||||
if settings.org_uuid:
|
||||
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
|
||||
@@ -44,17 +170,30 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def _make_request(
|
||||
self, method: str, endpoint: str, **kwargs: Any
|
||||
self,
|
||||
method: HttpMethod,
|
||||
endpoint: str,
|
||||
*,
|
||||
json: Any = None,
|
||||
params: dict[str, str] | None = None,
|
||||
timeout: float | None = None,
|
||||
verify: bool = True,
|
||||
) -> httpx.Response:
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
verify = kwargs.pop("verify", True)
|
||||
request_kwargs: RequestKwargs = {"headers": cast(dict[str, str], self.headers)}
|
||||
if json is not None:
|
||||
request_kwargs["json"] = json
|
||||
if params is not None:
|
||||
request_kwargs["params"] = params
|
||||
if timeout is not None:
|
||||
request_kwargs["timeout"] = timeout
|
||||
with httpx.Client(trust_env=False, verify=verify) as client:
|
||||
return client.request(method, url, headers=self.headers, **kwargs)
|
||||
return client.request(method, url, **request_kwargs)
|
||||
|
||||
def login_to_tool_repository(
|
||||
self, user_identifier: str | None = None
|
||||
) -> httpx.Response:
|
||||
payload = {}
|
||||
payload: LoginPayload = {}
|
||||
if user_identifier:
|
||||
payload["user_identifier"] = user_identifier
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
|
||||
@@ -65,7 +204,7 @@ class PlusAPI:
|
||||
async def get_agent(self, handle: str) -> httpx.Response:
|
||||
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
return await client.get(url, headers=self.headers)
|
||||
return await client.get(url, headers=cast(dict[str, str], self.headers))
|
||||
|
||||
def publish_tool(
|
||||
self,
|
||||
@@ -74,10 +213,10 @@ class PlusAPI:
|
||||
version: str,
|
||||
description: str | None,
|
||||
encoded_file: str,
|
||||
available_exports: list[dict[str, Any]] | None = None,
|
||||
tools_metadata: list[dict[str, Any]] | None = None,
|
||||
available_exports: list[AvailableExport] | None = None,
|
||||
tools_metadata: list[ToolMetadata] | None = None,
|
||||
) -> httpx.Response:
|
||||
params = {
|
||||
params: PublishToolPayload = {
|
||||
"handle": handle,
|
||||
"public": is_public,
|
||||
"version": version,
|
||||
@@ -90,6 +229,47 @@ class PlusAPI:
|
||||
}
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
|
||||
|
||||
def get_skill(
|
||||
self, org: str, name: str, version: str | None = None
|
||||
) -> httpx.Response:
|
||||
params: dict[str, str] = {}
|
||||
if version is not None:
|
||||
params["version"] = version
|
||||
return self._make_request(
|
||||
"GET",
|
||||
f"{self.SKILLS_RESOURCE}/{org}/{name}",
|
||||
params=params or None,
|
||||
)
|
||||
|
||||
def publish_skill(
|
||||
self,
|
||||
org: str,
|
||||
name: str,
|
||||
version: str,
|
||||
is_public: bool,
|
||||
description: str | None,
|
||||
encoded_file: str,
|
||||
) -> httpx.Response:
|
||||
payload = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"public": is_public,
|
||||
"description": description,
|
||||
"file": encoded_file,
|
||||
}
|
||||
return self._make_request("POST", self.SKILLS_RESOURCE, json=payload)
|
||||
|
||||
def list_skills(self, org: str | None = None) -> httpx.Response:
|
||||
params: dict[str, str] = {}
|
||||
if org is not None:
|
||||
params["org"] = org
|
||||
return self._make_request(
|
||||
"GET",
|
||||
self.SKILLS_RESOURCE,
|
||||
params=params or None,
|
||||
)
|
||||
|
||||
def deploy_by_name(self, project_name: str) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
|
||||
@@ -129,13 +309,13 @@ class PlusAPI:
|
||||
def list_crews(self) -> httpx.Response:
|
||||
return self._make_request("GET", self.CREWS_RESOURCE)
|
||||
|
||||
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
|
||||
def create_crew(self, payload: CreateCrewPayload) -> httpx.Response:
|
||||
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
|
||||
|
||||
def get_organizations(self) -> httpx.Response:
|
||||
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
|
||||
|
||||
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
|
||||
def initialize_trace_batch(self, payload: TraceBatchInitPayload) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
f"{self.TRACING_RESOURCE}/batches",
|
||||
@@ -144,7 +324,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def initialize_ephemeral_trace_batch(
|
||||
self, payload: dict[str, Any]
|
||||
self, payload: TraceBatchInitPayload
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -153,7 +333,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def send_trace_events(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
self, trace_batch_id: str, payload: TraceEventsPayload
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -163,7 +343,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def send_ephemeral_trace_events(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
self, trace_batch_id: str, payload: TraceEventsPayload
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST",
|
||||
@@ -173,7 +353,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def finalize_trace_batch(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
self, trace_batch_id: str, payload: TraceFinalizePayload
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
@@ -183,7 +363,7 @@ class PlusAPI:
|
||||
)
|
||||
|
||||
def finalize_ephemeral_trace_batch(
|
||||
self, trace_batch_id: str, payload: dict[str, Any]
|
||||
self, trace_batch_id: str, payload: TraceFinalizePayload
|
||||
) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
@@ -195,20 +375,28 @@ class PlusAPI:
|
||||
def mark_trace_batch_as_failed(
|
||||
self, trace_batch_id: str, error_message: str
|
||||
) -> httpx.Response:
|
||||
payload: TraceFailedPayload = {
|
||||
"status": "failed",
|
||||
"failure_reason": error_message,
|
||||
}
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
|
||||
json={"status": "failed", "failure_reason": error_message},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def mark_ephemeral_trace_batch_as_failed(
|
||||
self, trace_batch_id: str, error_message: str
|
||||
) -> httpx.Response:
|
||||
payload: TraceFailedPayload = {
|
||||
"status": "failed",
|
||||
"failure_reason": error_message,
|
||||
}
|
||||
return self._make_request(
|
||||
"PATCH",
|
||||
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
|
||||
json={"status": "failed", "failure_reason": error_message},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.5a5",
|
||||
"crewai==1.14.5",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5a5",
|
||||
"crewai-cli==1.14.5a5",
|
||||
"crewai-core==1.14.5",
|
||||
"crewai-cli==1.14.5",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.5a5",
|
||||
"crewai-tools==1.14.5",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
@@ -105,7 +105,7 @@ a2a = [
|
||||
"aiocache[redis,memcached]~=0.12.3",
|
||||
]
|
||||
file-processing = [
|
||||
"crewai-files==1.14.5a5",
|
||||
"crewai-files",
|
||||
]
|
||||
qdrant-edge = [
|
||||
"qdrant-edge-py>=0.6.0",
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""String templates for A2A (Agent-to-Agent) delegation prompts."""
|
||||
"""String templates for A2A (Agent-to-Agent) protocol messaging and status."""
|
||||
|
||||
from string import Template
|
||||
from typing import Final
|
||||
|
||||
|
||||
AVAILABLE_AGENTS_TEMPLATE: Final[Template] = Template(
|
||||
"\n<AVAILABLE_A2A_AGENTS>\n"
|
||||
"You can delegate to remote agents using the delegate_to_* tools below. "
|
||||
"Each tool's description lists the remote agent's capabilities — call the "
|
||||
"tool whose capabilities best match the task. Pass the question or sub-task "
|
||||
"to the remote agent via the tool's `message` argument; the tool returns "
|
||||
"the remote agent's response, which you should incorporate into your final "
|
||||
"answer. If the available agents are not a good fit, answer directly "
|
||||
"without calling a delegation tool.\n\n"
|
||||
" $available_a2a_agents"
|
||||
"\n</AVAILABLE_A2A_AGENTS>\n"
|
||||
"\n<AVAILABLE_A2A_AGENTS>\n $available_a2a_agents\n</AVAILABLE_A2A_AGENTS>\n"
|
||||
)
|
||||
PREVIOUS_A2A_CONVERSATION_TEMPLATE: Final[Template] = Template(
|
||||
"\n<PREVIOUS_A2A_CONVERSATION>\n"
|
||||
" $previous_a2a_conversation"
|
||||
"\n</PREVIOUS_A2A_CONVERSATION>\n"
|
||||
)
|
||||
CONVERSATION_TURN_INFO_TEMPLATE: Final[Template] = Template(
|
||||
"\n<CONVERSATION_PROGRESS>\n"
|
||||
' turn="$turn_count"\n'
|
||||
' max_turns="$max_turns"\n'
|
||||
" $warning"
|
||||
"\n</CONVERSATION_PROGRESS>\n"
|
||||
)
|
||||
UNAVAILABLE_AGENTS_NOTICE_TEMPLATE: Final[Template] = Template(
|
||||
"\n<A2A_AGENTS_STATUS>\n"
|
||||
@@ -24,3 +27,29 @@ UNAVAILABLE_AGENTS_NOTICE_TEMPLATE: Final[Template] = Template(
|
||||
" $unavailable_agents"
|
||||
"\n</A2A_AGENTS_STATUS>\n"
|
||||
)
|
||||
REMOTE_AGENT_COMPLETED_NOTICE: Final[str] = """
|
||||
<REMOTE_AGENT_STATUS>
|
||||
STATUS: COMPLETED
|
||||
The remote agent has finished processing your request. Their response is in the conversation history above.
|
||||
You MUST now:
|
||||
1. Extract the answer from the conversation history
|
||||
2. Set is_a2a=false
|
||||
3. Return the answer as your final message
|
||||
DO NOT send another request - the task is already done.
|
||||
</REMOTE_AGENT_STATUS>
|
||||
"""
|
||||
|
||||
REMOTE_AGENT_RESPONSE_NOTICE: Final[str] = """
|
||||
<REMOTE_AGENT_STATUS>
|
||||
STATUS: RESPONSE_RECEIVED
|
||||
The remote agent has responded. Their response is in the conversation history above.
|
||||
|
||||
You MUST now:
|
||||
1. Set is_a2a=false (the remote task is complete and cannot receive more messages)
|
||||
2. Provide YOUR OWN response to the original task based on the information received
|
||||
|
||||
IMPORTANT: Your response should be addressed to the USER who gave you the original task.
|
||||
Report what the remote agent told you in THIRD PERSON (e.g., "The remote agent said..." or "I learned that...").
|
||||
Do NOT address the remote agent directly or use "you" to refer to them.
|
||||
</REMOTE_AGENT_STATUS>
|
||||
"""
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
"""Tool-based A2A delegation.
|
||||
|
||||
Each remote A2A agent is exposed to the local LLM as a BaseTool. The local
|
||||
agent's normal tool-call loop drives multi-turn delegation: each tool call is
|
||||
one turn against the remote agent. Per-endpoint conversation state lives in
|
||||
``A2ADelegationState`` and is shared across the tools built for a single task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from a2a.types import Role, TaskState
|
||||
from pydantic import BaseModel, Field, PrivateAttr
|
||||
|
||||
from crewai.a2a.config import A2AClientConfig, A2AConfig
|
||||
from crewai.a2a.extensions.base import (
|
||||
A2AExtension,
|
||||
ConversationState,
|
||||
ExtensionRegistry,
|
||||
)
|
||||
from crewai.a2a.task_helpers import TaskStateResult
|
||||
from crewai.a2a.utils.delegation import aexecute_a2a_delegation, execute_a2a_delegation
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.a2a_events import A2AConversationCompletedEvent
|
||||
from crewai.tools.base_tool import BaseTool
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from a2a.types import AgentCard, Message
|
||||
|
||||
from crewai.task import Task
|
||||
|
||||
|
||||
_DELEGATE_PREFIX = "delegate_to_"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _EndpointState:
|
||||
"""Mutable per-endpoint conversation state across tool calls."""
|
||||
|
||||
conversation_history: list[Message] = field(default_factory=list)
|
||||
context_id: str | None = None
|
||||
task_id: str | None = None
|
||||
reference_task_ids: list[str] = field(default_factory=list)
|
||||
turn_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class A2ADelegationState:
|
||||
"""State shared across all A2A delegation tools for a single task execution."""
|
||||
|
||||
agent: Any
|
||||
task: Task
|
||||
extension_registry: ExtensionRegistry | None = None
|
||||
_per_endpoint: dict[str, _EndpointState] = field(default_factory=dict)
|
||||
|
||||
def _state_for(self, endpoint: str) -> _EndpointState:
|
||||
return self._per_endpoint.setdefault(endpoint, _EndpointState())
|
||||
|
||||
def _initial_ids_from_task(self, state: _EndpointState) -> None:
|
||||
if state.turn_count > 0:
|
||||
return
|
||||
task_config = self.task.config or {}
|
||||
if state.context_id is None:
|
||||
state.context_id = task_config.get("context_id")
|
||||
if state.task_id is None:
|
||||
state.task_id = task_config.get("task_id")
|
||||
if not state.reference_task_ids:
|
||||
state.reference_task_ids = list(task_config.get("reference_task_ids", []))
|
||||
|
||||
def delegate(
|
||||
self,
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
agent_card: AgentCard | None,
|
||||
message: str,
|
||||
) -> str:
|
||||
"""Run one delegation turn against ``config.endpoint``.
|
||||
|
||||
Returns the remote agent's response text, suitable for handing back to
|
||||
the local LLM as a tool result.
|
||||
"""
|
||||
return _run_delegation(self, config, agent_card, message, sync=True)
|
||||
|
||||
async def adelegate(
|
||||
self,
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
agent_card: AgentCard | None,
|
||||
message: str,
|
||||
) -> str:
|
||||
"""Async variant of :meth:`delegate`."""
|
||||
return await _run_delegation_async(self, config, agent_card, message)
|
||||
|
||||
|
||||
class _A2ADelegationArgs(BaseModel):
|
||||
"""Argument schema for A2A delegation tools."""
|
||||
|
||||
message: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"The question or task to send to the remote agent. Be specific and "
|
||||
"self-contained: the remote agent does not see your other tools or "
|
||||
"your prior reasoning."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class A2ADelegationTool(BaseTool):
|
||||
"""BaseTool that delegates one turn of conversation to a remote A2A agent.
|
||||
|
||||
Each instance is bound to a specific A2A endpoint via ``_config``. Calling
|
||||
``_run`` or ``_arun`` advances that endpoint's conversation by one turn and
|
||||
returns the remote agent's response text.
|
||||
"""
|
||||
|
||||
args_schema: type[BaseModel] = _A2ADelegationArgs
|
||||
|
||||
_config: A2AConfig | A2AClientConfig = PrivateAttr()
|
||||
_agent_card: AgentCard | None = PrivateAttr(default=None)
|
||||
_state: A2ADelegationState = PrivateAttr()
|
||||
|
||||
def _run(self, message: str) -> str:
|
||||
return self._state.delegate(self._config, self._agent_card, message)
|
||||
|
||||
async def _arun(self, message: str) -> str:
|
||||
return await self._state.adelegate(self._config, self._agent_card, message)
|
||||
|
||||
|
||||
def build_a2a_tools(
|
||||
a2a_agents: list[A2AConfig | A2AClientConfig],
|
||||
agent_cards: dict[str, AgentCard],
|
||||
state: A2ADelegationState,
|
||||
) -> list[BaseTool]:
|
||||
"""Build one ``A2ADelegationTool`` per available A2A agent.
|
||||
|
||||
Tool names collide-disambiguate with a numeric suffix; agents whose cards
|
||||
failed to fetch are skipped.
|
||||
"""
|
||||
tools: list[BaseTool] = []
|
||||
used_names: set[str] = set()
|
||||
for config in a2a_agents:
|
||||
card = agent_cards.get(config.endpoint)
|
||||
if card is None:
|
||||
continue
|
||||
name = _build_tool_name(card.name or "remote_agent", used_names)
|
||||
used_names.add(name)
|
||||
tool = A2ADelegationTool(
|
||||
name=name,
|
||||
description=_build_tool_description(card),
|
||||
max_usage_count=config.max_turns,
|
||||
)
|
||||
tool._config = config
|
||||
tool._agent_card = card
|
||||
tool._state = state
|
||||
tools.append(tool)
|
||||
return tools
|
||||
|
||||
|
||||
def _build_tool_name(card_name: str, used: set[str]) -> str:
|
||||
base = sanitize_tool_name(f"{_DELEGATE_PREFIX}{card_name}")
|
||||
if base not in used:
|
||||
return base
|
||||
for i in range(2, 1000):
|
||||
candidate = sanitize_tool_name(f"{base}_{i}")
|
||||
if candidate not in used:
|
||||
return candidate
|
||||
raise ValueError(f"Could not generate unique tool name for {card_name!r}")
|
||||
|
||||
|
||||
def _build_tool_description(card: AgentCard) -> str:
|
||||
lines: list[str] = [f"Delegate a task to the remote A2A agent {card.name!r}."]
|
||||
if card.description:
|
||||
lines.append(card.description.strip())
|
||||
if card.skills:
|
||||
skill_names = ", ".join(s.name for s in card.skills if s.name)
|
||||
if skill_names:
|
||||
lines.append(f"Capabilities: {skill_names}.")
|
||||
lines.append(
|
||||
"Use this tool only when the question matches the agent's capabilities. "
|
||||
"After receiving a response, prefer answering directly unless you need "
|
||||
"another round-trip."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _run_delegation(
|
||||
state: A2ADelegationState,
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
agent_card: AgentCard | None,
|
||||
message: str,
|
||||
*,
|
||||
sync: bool,
|
||||
) -> str:
|
||||
endpoint_state = state._state_for(config.endpoint)
|
||||
state._initial_ids_from_task(endpoint_state)
|
||||
|
||||
extension_states = _extract_extension_states(state, endpoint_state)
|
||||
metadata = _merged_metadata(state, endpoint_state, extension_states)
|
||||
agent_branch, accepted_output_modes = _turn_context(config)
|
||||
|
||||
a2a_result = execute_a2a_delegation(
|
||||
endpoint=config.endpoint,
|
||||
auth=config.auth,
|
||||
timeout=config.timeout,
|
||||
task_description=message,
|
||||
context_id=endpoint_state.context_id,
|
||||
task_id=endpoint_state.task_id,
|
||||
reference_task_ids=endpoint_state.reference_task_ids,
|
||||
metadata=metadata or None,
|
||||
extensions=(state.task.config or {}).get("extensions"),
|
||||
conversation_history=endpoint_state.conversation_history,
|
||||
agent_id=config.endpoint,
|
||||
agent_role=Role.user,
|
||||
agent_branch=agent_branch,
|
||||
response_model=config.response_model,
|
||||
turn_number=endpoint_state.turn_count + 1,
|
||||
updates=config.updates,
|
||||
transport=config.transport,
|
||||
from_task=state.task,
|
||||
from_agent=state.agent,
|
||||
client_extensions=getattr(config, "extensions", None),
|
||||
accepted_output_modes=accepted_output_modes,
|
||||
input_files=state.task.input_files,
|
||||
)
|
||||
return _finalize_turn(
|
||||
state, endpoint_state, config, agent_card, a2a_result, extension_states
|
||||
)
|
||||
|
||||
|
||||
async def _run_delegation_async(
|
||||
state: A2ADelegationState,
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
agent_card: AgentCard | None,
|
||||
message: str,
|
||||
) -> str:
|
||||
endpoint_state = state._state_for(config.endpoint)
|
||||
state._initial_ids_from_task(endpoint_state)
|
||||
|
||||
extension_states = _extract_extension_states(state, endpoint_state)
|
||||
metadata = _merged_metadata(state, endpoint_state, extension_states)
|
||||
agent_branch, accepted_output_modes = _turn_context(config)
|
||||
|
||||
a2a_result = await aexecute_a2a_delegation(
|
||||
endpoint=config.endpoint,
|
||||
auth=config.auth,
|
||||
timeout=config.timeout,
|
||||
task_description=message,
|
||||
context_id=endpoint_state.context_id,
|
||||
task_id=endpoint_state.task_id,
|
||||
reference_task_ids=endpoint_state.reference_task_ids,
|
||||
metadata=metadata or None,
|
||||
extensions=(state.task.config or {}).get("extensions"),
|
||||
conversation_history=endpoint_state.conversation_history,
|
||||
agent_id=config.endpoint,
|
||||
agent_role=Role.user,
|
||||
agent_branch=agent_branch,
|
||||
response_model=config.response_model,
|
||||
turn_number=endpoint_state.turn_count + 1,
|
||||
updates=config.updates,
|
||||
transport=config.transport,
|
||||
from_task=state.task,
|
||||
from_agent=state.agent,
|
||||
client_extensions=getattr(config, "extensions", None),
|
||||
accepted_output_modes=accepted_output_modes,
|
||||
input_files=state.task.input_files,
|
||||
)
|
||||
return _finalize_turn(
|
||||
state, endpoint_state, config, agent_card, a2a_result, extension_states
|
||||
)
|
||||
|
||||
|
||||
def _extract_extension_states(
|
||||
state: A2ADelegationState,
|
||||
endpoint_state: _EndpointState,
|
||||
) -> dict[type[A2AExtension], ConversationState]:
|
||||
if state.extension_registry and endpoint_state.conversation_history:
|
||||
return state.extension_registry.extract_all_states(
|
||||
endpoint_state.conversation_history
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def _merged_metadata(
|
||||
state: A2ADelegationState,
|
||||
endpoint_state: _EndpointState,
|
||||
extension_states: dict[type[A2AExtension], ConversationState],
|
||||
) -> dict[str, Any]:
|
||||
task_config = state.task.config or {}
|
||||
metadata: dict[str, Any] = dict(task_config.get("metadata") or {})
|
||||
if state.extension_registry and extension_states:
|
||||
metadata.update(state.extension_registry.prepare_all_metadata(extension_states))
|
||||
return metadata
|
||||
|
||||
|
||||
def _turn_context(
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
) -> tuple[Any | None, list[str] | None]:
|
||||
console_formatter = getattr(crewai_event_bus, "_console", None)
|
||||
agent_branch = None
|
||||
if console_formatter:
|
||||
agent_branch = getattr(
|
||||
console_formatter, "current_agent_branch", None
|
||||
) or getattr(console_formatter, "current_task_branch", None)
|
||||
|
||||
accepted_output_modes = None
|
||||
if isinstance(config, A2AClientConfig):
|
||||
accepted_output_modes = config.accepted_output_modes
|
||||
return agent_branch, accepted_output_modes
|
||||
|
||||
|
||||
def _finalize_turn(
|
||||
state: A2ADelegationState,
|
||||
endpoint_state: _EndpointState,
|
||||
config: A2AConfig | A2AClientConfig,
|
||||
agent_card: AgentCard | None,
|
||||
a2a_result: TaskStateResult,
|
||||
extension_states: dict[type[A2AExtension], ConversationState],
|
||||
) -> str:
|
||||
endpoint_state.conversation_history = list(a2a_result.get("history", []))
|
||||
if endpoint_state.conversation_history:
|
||||
latest = endpoint_state.conversation_history[-1]
|
||||
if latest.task_id is not None:
|
||||
endpoint_state.task_id = latest.task_id
|
||||
if latest.context_id is not None:
|
||||
endpoint_state.context_id = latest.context_id
|
||||
|
||||
endpoint_state.turn_count += 1
|
||||
status = a2a_result.get("status")
|
||||
|
||||
if status == TaskState.completed:
|
||||
if (
|
||||
endpoint_state.task_id is not None
|
||||
and endpoint_state.task_id not in endpoint_state.reference_task_ids
|
||||
):
|
||||
endpoint_state.reference_task_ids.append(endpoint_state.task_id)
|
||||
if state.task.config is None:
|
||||
state.task.config = {}
|
||||
state.task.config["reference_task_ids"] = list(
|
||||
endpoint_state.reference_task_ids
|
||||
)
|
||||
endpoint_state.task_id = None
|
||||
|
||||
result_text = str(a2a_result.get("result", ""))
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="completed",
|
||||
final_result=result_text,
|
||||
error=None,
|
||||
total_turns=endpoint_state.turn_count,
|
||||
from_task=state.task,
|
||||
from_agent=state.agent,
|
||||
endpoint=config.endpoint,
|
||||
a2a_agent_name=agent_card.name if agent_card else None,
|
||||
agent_card=agent_card.model_dump() if agent_card else None,
|
||||
),
|
||||
)
|
||||
return _apply_response_extensions(state, result_text, extension_states)
|
||||
|
||||
if status == TaskState.input_required:
|
||||
result_text = str(a2a_result.get("result", ""))
|
||||
return _apply_response_extensions(state, result_text, extension_states)
|
||||
|
||||
error_msg = a2a_result.get("error", "Unknown error")
|
||||
crewai_event_bus.emit(
|
||||
None,
|
||||
A2AConversationCompletedEvent(
|
||||
status="failed",
|
||||
final_result=None,
|
||||
error=error_msg,
|
||||
total_turns=endpoint_state.turn_count,
|
||||
from_task=state.task,
|
||||
from_agent=state.agent,
|
||||
endpoint=config.endpoint,
|
||||
a2a_agent_name=agent_card.name if agent_card else None,
|
||||
agent_card=agent_card.model_dump() if agent_card else None,
|
||||
),
|
||||
)
|
||||
return f"Remote agent error: {error_msg}"
|
||||
|
||||
|
||||
def _apply_response_extensions(
|
||||
state: A2ADelegationState,
|
||||
response_text: str,
|
||||
extension_states: dict[type[A2AExtension], ConversationState],
|
||||
) -> str:
|
||||
if not state.extension_registry:
|
||||
return response_text
|
||||
processed = state.extension_registry.process_response_with_all(
|
||||
response_text, extension_states
|
||||
)
|
||||
return processed if isinstance(processed, str) else str(processed)
|
||||
@@ -6,6 +6,8 @@ from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Literal,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
|
||||
@@ -55,6 +57,15 @@ Url = Annotated[
|
||||
]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AgentResponseProtocol(Protocol):
|
||||
"""Protocol for the dynamically created AgentResponse model."""
|
||||
|
||||
a2a_ids: tuple[str, ...]
|
||||
message: str
|
||||
is_a2a: bool
|
||||
|
||||
|
||||
class PartsMetadataDict(TypedDict, total=False):
|
||||
"""Metadata for A2A message parts.
|
||||
|
||||
|
||||
@@ -1,25 +1,75 @@
|
||||
"""Helpers for extracting A2A client configurations."""
|
||||
"""Response model utilities for A2A agent interactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig
|
||||
from crewai.types.utils import create_literals_from_strings
|
||||
|
||||
|
||||
A2AConfigTypes: TypeAlias = A2AConfig | A2AServerConfig | A2AClientConfig
|
||||
A2AClientConfigTypes: TypeAlias = A2AConfig | A2AClientConfig
|
||||
|
||||
|
||||
def extract_a2a_client_configs(
|
||||
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
|
||||
) -> list[A2AClientConfigTypes]:
|
||||
"""Return the client-side A2A configs from a possibly-mixed config list.
|
||||
def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel] | None:
|
||||
"""Create a dynamic AgentResponse model with Literal types for agent IDs.
|
||||
|
||||
Filters out :class:`A2AServerConfig`, which has no endpoint to delegate to.
|
||||
Args:
|
||||
agent_ids: List of available A2A agent IDs.
|
||||
|
||||
Returns:
|
||||
Dynamically created Pydantic model with Literal-constrained a2a_ids field,
|
||||
or None if agent_ids is empty.
|
||||
"""
|
||||
if not agent_ids:
|
||||
return None
|
||||
|
||||
DynamicLiteral = create_literals_from_strings(agent_ids) # noqa: N806
|
||||
|
||||
return create_model(
|
||||
"AgentResponse",
|
||||
a2a_ids=(
|
||||
tuple[DynamicLiteral, ...], # type: ignore[valid-type]
|
||||
Field(
|
||||
default_factory=tuple,
|
||||
max_length=len(agent_ids),
|
||||
description="A2A agent IDs to delegate to.",
|
||||
),
|
||||
),
|
||||
message=(
|
||||
str,
|
||||
Field(
|
||||
description="The message content. If is_a2a=true, this is sent to the A2A agent. If is_a2a=false, this is your final answer ending the conversation."
|
||||
),
|
||||
),
|
||||
is_a2a=(
|
||||
bool,
|
||||
Field(
|
||||
description="Set to false when the remote agent has answered your question - extract their answer and return it as your final message. Set to true ONLY if you need to ask a NEW, DIFFERENT question. NEVER repeat the same request - if the conversation history shows the agent already answered, set is_a2a=false immediately."
|
||||
),
|
||||
),
|
||||
__base__=BaseModel,
|
||||
)
|
||||
|
||||
|
||||
def extract_a2a_agent_ids_from_config(
|
||||
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
|
||||
) -> tuple[list[A2AClientConfigTypes], tuple[str, ...]]:
|
||||
"""Extract A2A agent IDs from A2A configuration.
|
||||
|
||||
Filters out A2AServerConfig since it doesn't have an endpoint for delegation.
|
||||
|
||||
Args:
|
||||
a2a_config: A2A configuration (any type).
|
||||
|
||||
Returns:
|
||||
Tuple of client A2A configs list and agent endpoint IDs.
|
||||
"""
|
||||
if a2a_config is None:
|
||||
return []
|
||||
return [], ()
|
||||
|
||||
configs: list[A2AConfigTypes]
|
||||
if isinstance(a2a_config, (A2AConfig, A2AClientConfig, A2AServerConfig)):
|
||||
@@ -27,6 +77,24 @@ def extract_a2a_client_configs(
|
||||
else:
|
||||
configs = a2a_config
|
||||
|
||||
return [
|
||||
client_configs: list[A2AClientConfigTypes] = [
|
||||
config for config in configs if isinstance(config, (A2AConfig, A2AClientConfig))
|
||||
]
|
||||
|
||||
return client_configs, tuple(config.endpoint for config in client_configs)
|
||||
|
||||
|
||||
def get_a2a_agents_and_response_model(
|
||||
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
|
||||
) -> tuple[list[A2AClientConfigTypes], type[BaseModel] | None]:
|
||||
"""Get A2A agent configs and response model.
|
||||
|
||||
Args:
|
||||
a2a_config: A2A configuration (any type).
|
||||
|
||||
Returns:
|
||||
Tuple of client A2A configs and response model.
|
||||
"""
|
||||
a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config)
|
||||
|
||||
return a2a_agents, create_agent_response_model(agent_ids)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,12 @@ from crewai.utilities.token_counter_callback import TokenCalcHandler
|
||||
from crewai.utilities.training_handler import CrewTrainingHandler
|
||||
|
||||
|
||||
try:
|
||||
from crewai.a2a.types import AgentResponseProtocol
|
||||
except ImportError:
|
||||
AgentResponseProtocol = None # type: ignore[assignment, misc]
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crewai_files import FileInput
|
||||
|
||||
@@ -214,7 +220,11 @@ class Agent(BaseAgent):
|
||||
str | BaseLLM | None,
|
||||
BeforeValidator(_validate_llm_ref),
|
||||
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
|
||||
] = Field(description="Language model that will run the agent.", default=None)
|
||||
] = Field(
|
||||
description="Language model that will run the agent.",
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
system_template: str | None = Field(
|
||||
default=None, description="System format for the agent."
|
||||
)
|
||||
@@ -424,7 +434,7 @@ class Agent(BaseAgent):
|
||||
from crewai.crew import Crew
|
||||
|
||||
if resolved_crew_skills is None:
|
||||
crew_skills: list[Path | SkillModel] | None = (
|
||||
crew_skills: list[Path | SkillModel | str] | None = (
|
||||
self.crew.skills
|
||||
if isinstance(self.crew, Crew) and isinstance(self.crew.skills, list)
|
||||
else None
|
||||
@@ -436,7 +446,7 @@ class Agent(BaseAgent):
|
||||
return
|
||||
|
||||
needs_work = self.skills and any(
|
||||
isinstance(s, Path)
|
||||
isinstance(s, (Path, str))
|
||||
or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS)
|
||||
for s in self.skills
|
||||
)
|
||||
@@ -444,14 +454,28 @@ class Agent(BaseAgent):
|
||||
return
|
||||
|
||||
seen: set[str] = set()
|
||||
resolved: list[Path | SkillModel] = []
|
||||
items: list[Path | SkillModel] = list(self.skills) if self.skills else []
|
||||
resolved: list[Path | SkillModel | str] = []
|
||||
items: list[Path | SkillModel | str] = list(self.skills) if self.skills else []
|
||||
|
||||
if crew_skills:
|
||||
items.extend(crew_skills)
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, Path):
|
||||
if isinstance(item, str):
|
||||
from crewai.skills.registry import (
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
resolve_registry_ref,
|
||||
)
|
||||
|
||||
if is_registry_ref(item):
|
||||
skill = resolve_registry_ref(item, source=self)
|
||||
org, _ = parse_registry_ref(item)
|
||||
dedup_key = f"{org}/{skill.name}"
|
||||
if dedup_key not in seen:
|
||||
seen.add(dedup_key)
|
||||
resolved.append(skill)
|
||||
elif isinstance(item, Path):
|
||||
discovered = discover_skills(item, source=self)
|
||||
for skill in discovered:
|
||||
if skill.name not in seen:
|
||||
@@ -626,7 +650,15 @@ class Agent(BaseAgent):
|
||||
|
||||
result = process_tool_results(self, result)
|
||||
|
||||
output_for_event = result if isinstance(result, str) else str(result)
|
||||
output_for_event = result
|
||||
if (
|
||||
AgentResponseProtocol is not None
|
||||
and isinstance(result, BaseModel)
|
||||
and isinstance(result, AgentResponseProtocol)
|
||||
):
|
||||
output_for_event = str(result.message)
|
||||
elif not isinstance(result, str):
|
||||
output_for_event = str(result)
|
||||
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
|
||||
@@ -51,7 +51,10 @@ class LangGraphAgentAdapter(BaseAgentAdapter):
|
||||
_graph: Any = PrivateAttr(default=None)
|
||||
_memory: Any = PrivateAttr(default=None)
|
||||
_max_iterations: int = PrivateAttr(default=10)
|
||||
function_calling_llm: Any = Field(default=None)
|
||||
function_calling_llm: Any = Field(
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
step_callback: SerializableCallable | None = Field(default=None)
|
||||
|
||||
model: str = Field(default="gpt-4o")
|
||||
|
||||
@@ -60,7 +60,10 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
|
||||
_openai_agent: OpenAIAgentProtocol = PrivateAttr()
|
||||
_logger: Logger = PrivateAttr(default_factory=Logger)
|
||||
_active_thread: str | None = PrivateAttr(default=None)
|
||||
function_calling_llm: Any = Field(default=None)
|
||||
function_calling_llm: Any = Field(
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
step_callback: Any = Field(default=None)
|
||||
_tool_adapter: OpenAIAgentToolAdapter = PrivateAttr()
|
||||
_converter_adapter: OpenAIConverterAdapter = PrivateAttr()
|
||||
|
||||
@@ -31,13 +31,13 @@ from crewai.agents.tools_handler import ToolsHandler
|
||||
from crewai.events.base_events import set_emission_counter
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.event_context import restore_event_scope, set_last_event_id
|
||||
from crewai.knowledge.knowledge import Knowledge
|
||||
from crewai.knowledge.knowledge import Knowledge, _resolve_knowledge_sources
|
||||
from crewai.knowledge.knowledge_config import KnowledgeConfig
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.mcp.config import MCPServerConfig
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.security.security_config import SecurityConfig
|
||||
@@ -127,6 +127,13 @@ def _validate_executor_ref(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_executor_ref(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
result: dict[str, Any] = value.model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_llm_ref(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -251,14 +258,13 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
max_iter: int = Field(
|
||||
default=25, description="Maximum iterations for an agent to execute a task"
|
||||
)
|
||||
agent_executor: SerializeAsAny[BaseAgentExecutor] | None = Field(
|
||||
default=None, description="An instance of the CrewAgentExecutor class."
|
||||
)
|
||||
|
||||
@field_validator("agent_executor", mode="before")
|
||||
@classmethod
|
||||
def _validate_agent_executor(cls, v: Any) -> Any:
|
||||
return _validate_executor_ref(v)
|
||||
agent_executor: Annotated[
|
||||
SerializeAsAny[BaseAgentExecutor] | None,
|
||||
BeforeValidator(_validate_executor_ref),
|
||||
PlainSerializer(
|
||||
_serialize_executor_ref, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None, description="An instance of the CrewAgentExecutor class.")
|
||||
|
||||
llm: Annotated[
|
||||
str | BaseLLM | None,
|
||||
@@ -288,7 +294,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
knowledge: Knowledge | None = Field(
|
||||
default=None, description="Knowledge for the agent."
|
||||
)
|
||||
knowledge_sources: list[BaseKnowledgeSource] | None = Field(
|
||||
knowledge_sources: Annotated[
|
||||
list[BaseKnowledgeSource] | None,
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(
|
||||
default=None,
|
||||
description="Knowledge sources for the agent.",
|
||||
)
|
||||
@@ -326,7 +335,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
default=None,
|
||||
description="List of MCP server references. Supports 'https://server.com/path' for external servers and bare slugs like 'notion' for connected MCP integrations. Use '#tool_name' suffix for specific tools.",
|
||||
)
|
||||
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
|
||||
memory: Annotated[
|
||||
bool
|
||||
| Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Enable agent memory. Pass True for default Memory(), "
|
||||
@@ -334,9 +350,9 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
"If not set, falls back to crew memory."
|
||||
),
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
skills: list[Path | Skill | str] | None = Field(
|
||||
default=None,
|
||||
description="Agent Skills. Accepts paths for discovery or pre-loaded Skill objects.",
|
||||
description="Agent Skills. Accepts paths for discovery, pre-loaded Skill objects, or '@org/name' registry refs.",
|
||||
min_length=1,
|
||||
)
|
||||
execution_context: ExecutionContext | None = Field(default=None)
|
||||
@@ -397,8 +413,21 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
self.agent_executor._resuming = True
|
||||
if self.checkpoint_kickoff_event_id is not None:
|
||||
self._kickoff_event_id = self.checkpoint_kickoff_event_id
|
||||
self._rebind_memory_view()
|
||||
self._restore_event_scope(state)
|
||||
|
||||
def _rebind_memory_view(self) -> None:
|
||||
"""Reattach a fresh ``Memory`` to a restored ``MemoryScope``/``MemorySlice``.
|
||||
|
||||
Checkpoint JSON omits the live ``Memory`` dependency, so scoped
|
||||
memory views raise ``RuntimeError`` on first use after restore.
|
||||
"""
|
||||
if (
|
||||
isinstance(self.memory, MemoryScope | MemorySlice)
|
||||
and self.memory._memory is None
|
||||
):
|
||||
self.memory.bind(Memory())
|
||||
|
||||
def _restore_event_scope(self, state: RuntimeState) -> None:
|
||||
"""Rebuild the event scope stack from the checkpoint's event record.
|
||||
|
||||
@@ -429,6 +458,20 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
def process_model_config(cls, values: Any) -> dict[str, Any]:
|
||||
return process_config(values, cls)
|
||||
|
||||
@field_validator("skills", mode="before")
|
||||
@classmethod
|
||||
def coerce_skill_strings(cls, skills: Any) -> Any:
|
||||
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
|
||||
if not isinstance(skills, list):
|
||||
return skills
|
||||
result = []
|
||||
for item in skills:
|
||||
if isinstance(item, str) and not item.startswith("@"):
|
||||
result.append(Path(item))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator("tools")
|
||||
@classmethod
|
||||
def validate_tools(cls, tools: list[Any]) -> list[BaseTool]:
|
||||
|
||||
@@ -93,11 +93,11 @@ from crewai.events.types.crew_events import (
|
||||
CrewTrainStartedEvent,
|
||||
)
|
||||
from crewai.flow.flow_trackable import FlowTrackable
|
||||
from crewai.knowledge.knowledge import Knowledge
|
||||
from crewai.knowledge.knowledge import Knowledge, _resolve_knowledge_sources
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.process import Process
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
@@ -223,7 +223,14 @@ class Crew(FlowTrackable, BaseModel):
|
||||
] = Field(default_factory=list)
|
||||
process: Process = Field(default=Process.sequential)
|
||||
verbose: bool = Field(default=False)
|
||||
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
|
||||
memory: Annotated[
|
||||
bool
|
||||
| Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Enable crew memory. Pass True for default Memory(), "
|
||||
@@ -251,7 +258,11 @@ class Crew(FlowTrackable, BaseModel):
|
||||
str | LLM | None,
|
||||
BeforeValidator(_validate_llm_ref),
|
||||
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
|
||||
] = Field(description="Language model that will run the agent.", default=None)
|
||||
] = Field(
|
||||
description="Language model that will run the agent.",
|
||||
default=None,
|
||||
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
|
||||
)
|
||||
config: Json[dict[str, Any]] | dict[str, Any] | None = Field(default=None)
|
||||
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
|
||||
share_crew: bool | None = Field(default=False)
|
||||
@@ -318,7 +329,10 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default_factory=list,
|
||||
description="list of execution logs for tasks",
|
||||
)
|
||||
knowledge_sources: list[BaseKnowledgeSource] | None = Field(
|
||||
knowledge_sources: Annotated[
|
||||
list[BaseKnowledgeSource] | None,
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Knowledge sources for the crew. Add knowledge sources to the "
|
||||
@@ -337,9 +351,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default=None,
|
||||
description="Knowledge for the crew.",
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
skills: list[Path | Skill | str] | None = Field(
|
||||
default=None,
|
||||
description="Skill search paths or pre-loaded Skill objects applied to all agents in the crew.",
|
||||
description="Skill search paths, pre-loaded Skill objects, or '@org/name' registry refs applied to all agents in the crew.",
|
||||
)
|
||||
|
||||
security_config: SecurityConfig = Field(
|
||||
@@ -473,8 +487,42 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if self.checkpoint_train is not None:
|
||||
self._train = self.checkpoint_train
|
||||
|
||||
self._rebind_memory_views()
|
||||
self._restore_event_scope()
|
||||
|
||||
def _rebind_memory_views(self) -> None:
|
||||
"""Reattach a live ``Memory`` to restored ``MemoryScope``/``MemorySlice`` views.
|
||||
|
||||
Checkpoint JSON omits the live ``Memory`` dependency on scope/slice
|
||||
views, so after restore they raise ``RuntimeError`` on first use.
|
||||
Prefer the crew's restored ``Memory`` (from ``create_crew_memory``
|
||||
or a ``Crew.memory=Memory(...)`` instance) so all views share one
|
||||
backing store; fall back to a fresh ``Memory()`` only if nothing is
|
||||
available.
|
||||
"""
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
backing: Memory | None = None
|
||||
if isinstance(self._memory, Memory):
|
||||
backing = self._memory
|
||||
elif isinstance(self.memory, Memory):
|
||||
backing = self.memory
|
||||
|
||||
def _ensure(view: Any) -> None:
|
||||
nonlocal backing
|
||||
if not isinstance(view, MemoryScope | MemorySlice):
|
||||
return
|
||||
if view._memory is not None:
|
||||
return
|
||||
if backing is None:
|
||||
backing = Memory()
|
||||
view.bind(backing)
|
||||
|
||||
_ensure(self.memory)
|
||||
for agent in self.agents:
|
||||
_ensure(agent.memory)
|
||||
|
||||
def _restore_event_scope(self) -> None:
|
||||
"""Rebuild the event scope stack from the checkpoint's event record."""
|
||||
from crewai.events.base_events import set_emission_counter
|
||||
@@ -522,6 +570,20 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if max_seq > 0:
|
||||
set_emission_counter(max_seq)
|
||||
|
||||
@field_validator("skills", mode="before")
|
||||
@classmethod
|
||||
def coerce_skill_strings(cls, skills: Any) -> Any:
|
||||
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
|
||||
if not isinstance(skills, list):
|
||||
return skills
|
||||
result = []
|
||||
for item in skills:
|
||||
if isinstance(item, str) and not item.startswith("@"):
|
||||
result.append(Path(item))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
@classmethod
|
||||
def _deny_user_set_id(cls, v: UUID4 | None, info: Any) -> UUID4 | None:
|
||||
|
||||
@@ -6,6 +6,14 @@ import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from crewai_core.plus_api import (
|
||||
TraceBatchInitPayload,
|
||||
TraceBatchMetadata,
|
||||
TraceEventsPayload,
|
||||
TraceExecutionContext,
|
||||
TraceExecutionMetadata,
|
||||
TraceFinalizePayload,
|
||||
)
|
||||
from crewai_core.settings import Settings
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -123,25 +131,27 @@ class TraceBatchManager:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = {
|
||||
execution_context: TraceExecutionContext = {
|
||||
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
|
||||
"crew_name": execution_metadata.get("crew_name", None),
|
||||
"flow_name": execution_metadata.get("flow_name", None),
|
||||
"crewai_version": self.current_batch.version,
|
||||
"privacy_level": user_context.get("privacy_level", "standard"),
|
||||
}
|
||||
execution_metadata_payload: TraceExecutionMetadata = {
|
||||
"expected_duration_estimate": execution_metadata.get(
|
||||
"expected_duration_estimate", 300
|
||||
),
|
||||
"agent_count": execution_metadata.get("agent_count", 0),
|
||||
"task_count": execution_metadata.get("task_count", 0),
|
||||
"flow_method_count": execution_metadata.get("flow_method_count", 0),
|
||||
"execution_started_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
payload: TraceBatchInitPayload = {
|
||||
"trace_id": self.current_batch.batch_id,
|
||||
"execution_type": execution_metadata.get("execution_type", "crew"),
|
||||
"execution_context": {
|
||||
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
|
||||
"crew_name": execution_metadata.get("crew_name", None),
|
||||
"flow_name": execution_metadata.get("flow_name", None),
|
||||
"crewai_version": self.current_batch.version,
|
||||
"privacy_level": user_context.get("privacy_level", "standard"),
|
||||
},
|
||||
"execution_metadata": {
|
||||
"expected_duration_estimate": execution_metadata.get(
|
||||
"expected_duration_estimate", 300
|
||||
),
|
||||
"agent_count": execution_metadata.get("agent_count", 0),
|
||||
"task_count": execution_metadata.get("task_count", 0),
|
||||
"flow_method_count": execution_metadata.get("flow_method_count", 0),
|
||||
"execution_started_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
"execution_context": execution_context,
|
||||
"execution_metadata": execution_metadata_payload,
|
||||
}
|
||||
if use_ephemeral:
|
||||
payload["ephemeral_trace_id"] = self.current_batch.batch_id
|
||||
@@ -264,13 +274,14 @@ class TraceBatchManager:
|
||||
if not self.plus_api or not self.trace_batch_id or not self.event_buffer:
|
||||
return 500
|
||||
try:
|
||||
payload = {
|
||||
batch_metadata: TraceBatchMetadata = {
|
||||
"events_count": len(self.event_buffer),
|
||||
"batch_sequence": 1,
|
||||
"is_final_batch": False,
|
||||
}
|
||||
payload: TraceEventsPayload = {
|
||||
"events": [event.to_dict() for event in self.event_buffer],
|
||||
"batch_metadata": {
|
||||
"events_count": len(self.event_buffer),
|
||||
"batch_sequence": 1,
|
||||
"is_final_batch": False,
|
||||
},
|
||||
"batch_metadata": batch_metadata,
|
||||
}
|
||||
|
||||
response = (
|
||||
@@ -364,7 +375,7 @@ class TraceBatchManager:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = {
|
||||
payload: TraceFinalizePayload = {
|
||||
"status": "completed",
|
||||
"duration_ms": self.calculate_duration("execution"),
|
||||
"final_event_count": events_count,
|
||||
|
||||
@@ -60,3 +60,20 @@ class SkillLoadFailedEvent(SkillEvent):
|
||||
|
||||
type: Literal["skill_load_failed"] = "skill_load_failed"
|
||||
error: str
|
||||
|
||||
|
||||
class SkillDownloadStartedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download begins."""
|
||||
|
||||
type: Literal["skill_download_started"] = "skill_download_started"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class SkillDownloadCompletedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download completes."""
|
||||
|
||||
type: Literal["skill_download_completed"] = "skill_download_completed"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
cache_path: Path | None = None
|
||||
|
||||
@@ -113,7 +113,7 @@ from crewai.flow.utils import (
|
||||
is_flow_method_name,
|
||||
is_simple_flow_condition,
|
||||
)
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.state.checkpoint_config import (
|
||||
CheckpointConfig,
|
||||
@@ -159,6 +159,39 @@ def _resolve_persistence(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_persistence(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, FlowPersistence):
|
||||
return value.model_dump(mode="json")
|
||||
raise TypeError(
|
||||
f"Cannot serialize Flow.persistence of type {type(value).__name__}: "
|
||||
"expected FlowPersistence or None."
|
||||
)
|
||||
|
||||
|
||||
def _validate_input_provider(value: Any) -> Any:
|
||||
if value is None or isinstance(value, InputProvider):
|
||||
return value
|
||||
from crewai.types.callback import _dotted_path_to_instance
|
||||
|
||||
resolved = _dotted_path_to_instance(value)
|
||||
if resolved is None or isinstance(resolved, InputProvider):
|
||||
return resolved
|
||||
raise ValueError(
|
||||
f"Resolved input_provider {resolved!r} does not implement the "
|
||||
"InputProvider protocol (missing request_input)."
|
||||
)
|
||||
|
||||
|
||||
def _serialize_input_provider(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
from crewai.types.callback import _instance_to_dotted_path
|
||||
|
||||
return _instance_to_dotted_path(value)
|
||||
|
||||
|
||||
_INITIAL_STATE_CLASS_MARKER = "__crewai_pydantic_class_schema__"
|
||||
|
||||
|
||||
@@ -949,15 +982,30 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
name: str | None = Field(default=None)
|
||||
tracing: bool | None = Field(default=None)
|
||||
stream: bool = Field(default=False)
|
||||
memory: Memory | MemoryScope | MemorySlice | None = Field(default=None)
|
||||
input_provider: InputProvider | None = Field(default=None)
|
||||
memory: Annotated[
|
||||
Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(default=None)
|
||||
input_provider: Annotated[
|
||||
InputProvider | None,
|
||||
BeforeValidator(_validate_input_provider),
|
||||
PlainSerializer(
|
||||
_serialize_input_provider, return_type=str | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None)
|
||||
suppress_flow_events: bool = Field(default=False)
|
||||
human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list)
|
||||
last_human_feedback: HumanFeedbackResult | None = Field(default=None)
|
||||
|
||||
persistence: Annotated[
|
||||
SerializeAsAny[FlowPersistence] | Any,
|
||||
SerializeAsAny[FlowPersistence] | None,
|
||||
BeforeValidator(lambda v, _: _resolve_persistence(v)),
|
||||
PlainSerializer(
|
||||
_serialize_persistence, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None)
|
||||
max_method_calls: int = Field(default=100)
|
||||
|
||||
@@ -1050,6 +1098,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
}
|
||||
if self.checkpoint_state is not None:
|
||||
self._restore_state(self.checkpoint_state)
|
||||
if (
|
||||
isinstance(self.memory, MemoryScope | MemorySlice)
|
||||
and self.memory._memory is None
|
||||
):
|
||||
self.memory.bind(Memory())
|
||||
restore_event_scope(())
|
||||
reset_last_event_id()
|
||||
|
||||
|
||||
@@ -1,16 +1,89 @@
|
||||
import os
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer
|
||||
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.knowledge.source.crew_docling_source import CrewDoclingSource
|
||||
from crewai.knowledge.source.csv_knowledge_source import CSVKnowledgeSource
|
||||
from crewai.knowledge.source.excel_knowledge_source import ExcelKnowledgeSource
|
||||
from crewai.knowledge.source.json_knowledge_source import JSONKnowledgeSource
|
||||
from crewai.knowledge.source.pdf_knowledge_source import PDFKnowledgeSource
|
||||
from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSource
|
||||
from crewai.knowledge.source.text_file_knowledge_source import (
|
||||
TextFileKnowledgeSource,
|
||||
)
|
||||
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
|
||||
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.rag.types import SearchResult
|
||||
|
||||
|
||||
_KNOWN_SOURCES: dict[str, type[BaseKnowledgeSource]] = {
|
||||
"string": StringKnowledgeSource,
|
||||
"docling": CrewDoclingSource,
|
||||
"csv": CSVKnowledgeSource,
|
||||
"excel": ExcelKnowledgeSource,
|
||||
"json": JSONKnowledgeSource,
|
||||
"pdf": PDFKnowledgeSource,
|
||||
"text_file": TextFileKnowledgeSource,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_knowledge_sources(value: Any) -> Any:
|
||||
"""Coerce list of dicts into typed BaseKnowledgeSource subclasses via source_type.
|
||||
|
||||
Pass-through for anything else (existing instances, mocks).
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return value
|
||||
resolved: list[Any] = []
|
||||
for idx, item in enumerate(value):
|
||||
if isinstance(item, dict):
|
||||
tag = item.get("source_type")
|
||||
if not isinstance(tag, str):
|
||||
resolved.append(item)
|
||||
continue
|
||||
cls = _KNOWN_SOURCES.get(tag)
|
||||
if cls is None:
|
||||
raise ValueError(
|
||||
f"Unknown source_type={tag!r} at index {idx}: "
|
||||
f"expected one of {sorted(_KNOWN_SOURCES)}"
|
||||
)
|
||||
try:
|
||||
resolved.append(cls.model_validate(item))
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"Failed to validate knowledge source at index {idx} "
|
||||
f"with source_type={tag!r}: {exc}"
|
||||
) from exc
|
||||
else:
|
||||
resolved.append(item)
|
||||
return resolved
|
||||
|
||||
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false" # removes logging from fastembed
|
||||
|
||||
|
||||
def _serialize_embedder_spec(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, BaseEmbeddingsProvider):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, type) and issubclass(value, BaseEmbeddingsProvider):
|
||||
raise TypeError(
|
||||
f"Cannot checkpoint embedder class {value.__module__}.{value.__qualname__}: "
|
||||
"build_embedder requires an instance or ProviderSpec dict, not a class. "
|
||||
"Instantiate the provider before assigning it to Knowledge.embedder."
|
||||
)
|
||||
raise TypeError(
|
||||
f"Cannot serialize embedder of type {type(value).__name__}: "
|
||||
"expected ProviderSpec dict or BaseEmbeddingsProvider instance."
|
||||
)
|
||||
|
||||
|
||||
class Knowledge(BaseModel):
|
||||
"""
|
||||
Knowledge is a collection of sources and setup for the vector store to save and query relevant context.
|
||||
@@ -20,10 +93,18 @@ class Knowledge(BaseModel):
|
||||
embedder: EmbedderConfig | None = None
|
||||
"""
|
||||
|
||||
sources: list[BaseKnowledgeSource] = Field(default_factory=list)
|
||||
sources: Annotated[
|
||||
list[BaseKnowledgeSource],
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(default_factory=list)
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
storage: KnowledgeStorage | None = Field(default=None)
|
||||
embedder: EmbedderConfig | None = None
|
||||
embedder: Annotated[
|
||||
EmbedderConfig | None,
|
||||
PlainSerializer(
|
||||
_serialize_embedder_spec, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = None
|
||||
collection_name: str | None = None
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -13,7 +13,9 @@ class BaseKnowledgeSource(BaseModel, ABC):
|
||||
chunk_size: int = 4000
|
||||
chunk_overlap: int = 200
|
||||
chunks: list[str] = Field(default_factory=list)
|
||||
chunk_embeddings: list[np.ndarray[Any, np.dtype[Any]]] = Field(default_factory=list)
|
||||
chunk_embeddings: list[np.ndarray[Any, np.dtype[Any]]] = Field(
|
||||
default_factory=list, exclude=True
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
storage: KnowledgeStorage | None = Field(default=None)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class CrewDoclingSource(BaseKnowledgeSource):
|
||||
|
||||
_logger: Logger = Logger(verbose=True)
|
||||
|
||||
source_type: Literal["docling"] = "docling"
|
||||
file_path: list[Path | str] | None = Field(default=None)
|
||||
file_paths: list[Path | str] = Field(default_factory=list)
|
||||
chunks: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -7,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class CSVKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries CSV file content using embeddings."""
|
||||
|
||||
source_type: Literal["csv"] = "csv"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess CSV file content."""
|
||||
content_dict = {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
@@ -16,6 +16,7 @@ class ExcelKnowledgeSource(BaseKnowledgeSource):
|
||||
|
||||
_logger: Logger = Logger(verbose=True)
|
||||
|
||||
source_type: Literal["excel"] = "excel"
|
||||
file_path: Path | list[Path] | str | list[str] | None = Field(
|
||||
default=None,
|
||||
description="[Deprecated] The path to the file. Use file_paths instead.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -8,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class JSONKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries JSON file content using embeddings."""
|
||||
|
||||
source_type: Literal["json"] = "json"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess JSON file content."""
|
||||
content: dict[Path, str] = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -7,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class PDFKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries PDF file content using embeddings."""
|
||||
|
||||
source_type: Literal["pdf"] = "pdf"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess PDF file content."""
|
||||
pdfplumber = self._import_pdfplumber()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@@ -8,6 +8,7 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
class StringKnowledgeSource(BaseKnowledgeSource):
|
||||
"""A knowledge source that stores and queries plain text content using embeddings."""
|
||||
|
||||
source_type: Literal["string"] = "string"
|
||||
content: str = Field(...)
|
||||
collection_name: str | None = Field(default=None)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -6,6 +7,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class TextFileKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries text file content using embeddings."""
|
||||
|
||||
source_type: Literal["text_file"] = "text_file"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess text file content."""
|
||||
content = {}
|
||||
|
||||
@@ -121,11 +121,11 @@ def _kickoff_with_a2a_support(
|
||||
Returns:
|
||||
LiteAgentOutput from either local execution or A2A delegation.
|
||||
"""
|
||||
from crewai.a2a.utils.response_model import extract_a2a_client_configs
|
||||
from crewai.a2a.utils.response_model import get_a2a_agents_and_response_model
|
||||
from crewai.a2a.wrapper import _execute_task_with_a2a
|
||||
from crewai.task import Task
|
||||
|
||||
a2a_agents = extract_a2a_client_configs(agent.a2a)
|
||||
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(agent.a2a)
|
||||
|
||||
if not a2a_agents:
|
||||
return original_kickoff(messages, response_format, input_files)
|
||||
@@ -160,6 +160,7 @@ def _kickoff_with_a2a_support(
|
||||
a2a_agents=a2a_agents,
|
||||
original_fn=task_to_kickoff_adapter,
|
||||
task=fake_task,
|
||||
agent_response_model=agent_response_model,
|
||||
context=None,
|
||||
tools=None,
|
||||
extension_registry=extension_registry,
|
||||
|
||||
@@ -940,6 +940,21 @@ class LLM(BaseLLM):
|
||||
self._track_token_usage_internal(usage_info)
|
||||
self._handle_streaming_callbacks(callbacks, usage_info, last_chunk)
|
||||
|
||||
if accumulated_tool_args and not available_functions:
|
||||
tool_calls_list: list[ChatCompletionDeltaToolCall] = [
|
||||
ChatCompletionDeltaToolCall(
|
||||
index=idx,
|
||||
function=Function(
|
||||
name=tool_arg.function.name,
|
||||
arguments=tool_arg.function.arguments,
|
||||
),
|
||||
)
|
||||
for idx, tool_arg in sorted(accumulated_tool_args.items())
|
||||
if tool_arg.function.name
|
||||
]
|
||||
if tool_calls_list:
|
||||
return tool_calls_list
|
||||
|
||||
if not tool_calls or not available_functions:
|
||||
if response_model and self.is_litellm:
|
||||
instructor_instance = InternalInstructor(
|
||||
@@ -1535,8 +1550,7 @@ class LLM(BaseLLM):
|
||||
if usage_info:
|
||||
self._track_token_usage_internal(usage_info)
|
||||
|
||||
if accumulated_tool_args and available_functions:
|
||||
# Convert accumulated tool args to ChatCompletionDeltaToolCall objects
|
||||
if accumulated_tool_args:
|
||||
tool_calls_list: list[ChatCompletionDeltaToolCall] = [
|
||||
ChatCompletionDeltaToolCall(
|
||||
index=idx,
|
||||
@@ -1545,21 +1559,24 @@ class LLM(BaseLLM):
|
||||
arguments=tool_arg.function.arguments,
|
||||
),
|
||||
)
|
||||
for idx, tool_arg in accumulated_tool_args.items()
|
||||
for idx, tool_arg in sorted(accumulated_tool_args.items())
|
||||
if tool_arg.function.name
|
||||
]
|
||||
|
||||
if tool_calls_list:
|
||||
result = self._handle_streaming_tool_calls(
|
||||
tool_calls=tool_calls_list,
|
||||
accumulated_tool_args=accumulated_tool_args,
|
||||
available_functions=available_functions,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
response_id=response_id,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
if available_functions:
|
||||
result = self._handle_streaming_tool_calls(
|
||||
tool_calls=tool_calls_list,
|
||||
accumulated_tool_args=accumulated_tool_args,
|
||||
available_functions=available_functions,
|
||||
from_task=from_task,
|
||||
from_agent=from_agent,
|
||||
response_id=response_id,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
else:
|
||||
return tool_calls_list
|
||||
|
||||
usage_dict = self._usage_to_dict(usage_info)
|
||||
self._handle_emit_call_events(
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.memory.types import (
|
||||
_RECALL_OVERSAMPLE_FACTOR,
|
||||
@@ -16,15 +17,35 @@ from crewai.memory.types import (
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
|
||||
def _ensure_memory_kind(value: Any) -> Any:
|
||||
"""Backfill ``memory_kind`` on legacy dicts that predate the discriminator.
|
||||
|
||||
Lets pre-1.14.6 configs/checkpoints flow into the discriminated
|
||||
``Memory | MemoryScope | MemorySlice`` union without crashing. Inference:
|
||||
``scopes`` key → ``slice``; ``root_path`` → ``scope``; else ``memory``.
|
||||
Pass-through for non-dict values (instances, ``bool``, ``None``).
|
||||
"""
|
||||
if isinstance(value, dict) and "memory_kind" not in value:
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
value["memory_kind"] = "scope"
|
||||
else:
|
||||
value["memory_kind"] = "memory"
|
||||
return value
|
||||
|
||||
|
||||
class MemoryScope(BaseModel):
|
||||
"""View of Memory restricted to a root path. All operations are scoped under that path."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["scope"] = "scope"
|
||||
|
||||
root_path: str = Field(default="/")
|
||||
|
||||
_memory: Memory = PrivateAttr()
|
||||
_root: str = PrivateAttr()
|
||||
_memory: Memory | None = PrivateAttr(default=None)
|
||||
_root: str = PrivateAttr(default="")
|
||||
|
||||
@model_validator(mode="wrap")
|
||||
@classmethod
|
||||
@@ -34,21 +55,38 @@ class MemoryScope(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemoryScope, got {type(data).__name__}")
|
||||
if "memory" not in data:
|
||||
raise ValueError("MemoryScope requires a 'memory' key")
|
||||
memory = data.pop("memory")
|
||||
memory = data.pop("memory", None)
|
||||
instance: MemoryScope = handler(data)
|
||||
instance._memory = memory
|
||||
if memory is not None:
|
||||
instance._memory = memory
|
||||
root = instance.root_path.rstrip("/") or ""
|
||||
if root and not root.startswith("/"):
|
||||
root = "/" + root
|
||||
instance._root = root
|
||||
return instance
|
||||
|
||||
def bind(self, memory: Memory) -> Self:
|
||||
"""Rebind the runtime ``Memory`` dependency after restore.
|
||||
|
||||
Required after deserializing from a checkpoint, since the live
|
||||
``Memory`` cannot be serialized.
|
||||
"""
|
||||
self._memory = memory
|
||||
return self
|
||||
|
||||
def _require_memory(self) -> Memory:
|
||||
"""Return the bound ``Memory`` or raise a clear error if missing."""
|
||||
if self._memory is None:
|
||||
raise RuntimeError(
|
||||
"MemoryScope is not bound to a Memory; call .bind(memory) "
|
||||
"after restore."
|
||||
)
|
||||
return self._memory
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
"""Whether the underlying memory is read-only."""
|
||||
return self._memory.read_only
|
||||
return self._require_memory().read_only
|
||||
|
||||
def _scope_path(self, scope: str | None) -> str:
|
||||
if not scope or scope == "/":
|
||||
@@ -73,7 +111,7 @@ class MemoryScope(BaseModel):
|
||||
) -> MemoryRecord | None:
|
||||
"""Remember content; scope is relative to this scope's root."""
|
||||
path = self._scope_path(scope)
|
||||
return self._memory.remember(
|
||||
return self._require_memory().remember(
|
||||
content,
|
||||
scope=path,
|
||||
categories=categories,
|
||||
@@ -96,7 +134,7 @@ class MemoryScope(BaseModel):
|
||||
) -> list[MemoryRecord]:
|
||||
"""Remember multiple items; scope is relative to this scope's root."""
|
||||
path = self._scope_path(scope)
|
||||
return self._memory.remember_many(
|
||||
return self._require_memory().remember_many(
|
||||
contents,
|
||||
scope=path,
|
||||
categories=categories,
|
||||
@@ -119,7 +157,7 @@ class MemoryScope(BaseModel):
|
||||
) -> list[MemoryMatch]:
|
||||
"""Recall within this scope (root path and below)."""
|
||||
search_scope = self._scope_path(scope) if scope else (self._root or "/")
|
||||
return self._memory.recall(
|
||||
return self._require_memory().recall(
|
||||
query,
|
||||
scope=search_scope,
|
||||
categories=categories,
|
||||
@@ -131,7 +169,7 @@ class MemoryScope(BaseModel):
|
||||
|
||||
def extract_memories(self, content: str) -> list[str]:
|
||||
"""Extract discrete memories from content; delegates to underlying Memory."""
|
||||
return self._memory.extract_memories(content)
|
||||
return self._require_memory().extract_memories(content)
|
||||
|
||||
def forget(
|
||||
self,
|
||||
@@ -143,7 +181,7 @@ class MemoryScope(BaseModel):
|
||||
) -> int:
|
||||
"""Forget within this scope."""
|
||||
prefix = self._scope_path(scope) if scope else (self._root or "/")
|
||||
return self._memory.forget(
|
||||
return self._require_memory().forget(
|
||||
scope=prefix,
|
||||
categories=categories,
|
||||
older_than=older_than,
|
||||
@@ -154,27 +192,27 @@ class MemoryScope(BaseModel):
|
||||
def list_scopes(self, path: str = "/") -> list[str]:
|
||||
"""List child scopes under path (relative to this scope's root)."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.list_scopes(full)
|
||||
return self._require_memory().list_scopes(full)
|
||||
|
||||
def info(self, path: str = "/") -> ScopeInfo:
|
||||
"""Info for path under this scope."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.info(full)
|
||||
return self._require_memory().info(full)
|
||||
|
||||
def tree(self, path: str = "/", max_depth: int = 3) -> str:
|
||||
"""Tree under path within this scope."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.tree(full, max_depth=max_depth)
|
||||
return self._require_memory().tree(full, max_depth=max_depth)
|
||||
|
||||
def list_categories(self, path: str | None = None) -> dict[str, int]:
|
||||
"""Categories in this scope; path None means this scope root."""
|
||||
full = self._scope_path(path) if path else (self._root or "/")
|
||||
return self._memory.list_categories(full)
|
||||
return self._require_memory().list_categories(full)
|
||||
|
||||
def reset(self, scope: str | None = None) -> None:
|
||||
"""Reset within this scope."""
|
||||
prefix = self._scope_path(scope) if scope else (self._root or "/")
|
||||
self._memory.reset(scope=prefix)
|
||||
self._require_memory().reset(scope=prefix)
|
||||
|
||||
def subscope(self, path: str) -> MemoryScope:
|
||||
"""Return a narrower scope under this scope."""
|
||||
@@ -191,11 +229,13 @@ class MemorySlice(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["slice"] = "slice"
|
||||
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
categories: list[str] | None = Field(default=None)
|
||||
read_only: bool = Field(default=True)
|
||||
|
||||
_memory: Memory = PrivateAttr()
|
||||
_memory: Memory | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="wrap")
|
||||
@classmethod
|
||||
@@ -205,14 +245,27 @@ class MemorySlice(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemorySlice, got {type(data).__name__}")
|
||||
if "memory" not in data:
|
||||
raise ValueError("MemorySlice requires a 'memory' key")
|
||||
memory = data.pop("memory")
|
||||
memory = data.pop("memory", None)
|
||||
data["scopes"] = [s.rstrip("/") or "/" for s in data.get("scopes", [])]
|
||||
instance: MemorySlice = handler(data)
|
||||
instance._memory = memory
|
||||
if memory is not None:
|
||||
instance._memory = memory
|
||||
return instance
|
||||
|
||||
def bind(self, memory: Memory) -> Self:
|
||||
"""Rebind the runtime ``Memory`` dependency after restore."""
|
||||
self._memory = memory
|
||||
return self
|
||||
|
||||
def _require_memory(self) -> Memory:
|
||||
"""Return the bound ``Memory`` or raise a clear error if missing."""
|
||||
if self._memory is None:
|
||||
raise RuntimeError(
|
||||
"MemorySlice is not bound to a Memory; call .bind(memory) "
|
||||
"after restore."
|
||||
)
|
||||
return self._memory
|
||||
|
||||
def remember(
|
||||
self,
|
||||
content: str,
|
||||
@@ -226,7 +279,7 @@ class MemorySlice(BaseModel):
|
||||
"""Remember into an explicit scope. No-op when read_only=True."""
|
||||
if self.read_only:
|
||||
return None
|
||||
return self._memory.remember(
|
||||
return self._require_memory().remember(
|
||||
content,
|
||||
scope=scope,
|
||||
categories=categories,
|
||||
@@ -250,7 +303,7 @@ class MemorySlice(BaseModel):
|
||||
cats = categories or self.categories
|
||||
all_matches: list[MemoryMatch] = []
|
||||
for sc in self.scopes:
|
||||
matches = self._memory.recall(
|
||||
matches = self._require_memory().recall(
|
||||
query,
|
||||
scope=sc,
|
||||
categories=cats,
|
||||
@@ -272,14 +325,14 @@ class MemorySlice(BaseModel):
|
||||
|
||||
def extract_memories(self, content: str) -> list[str]:
|
||||
"""Extract discrete memories from content; delegates to underlying Memory."""
|
||||
return self._memory.extract_memories(content)
|
||||
return self._require_memory().extract_memories(content)
|
||||
|
||||
def list_scopes(self, path: str = "/") -> list[str]:
|
||||
"""List scopes across all slice roots."""
|
||||
out: list[str] = []
|
||||
for sc in self.scopes:
|
||||
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
|
||||
out.extend(self._memory.list_scopes(full))
|
||||
out.extend(self._require_memory().list_scopes(full))
|
||||
return sorted(set(out))
|
||||
|
||||
def info(self, path: str = "/") -> ScopeInfo:
|
||||
@@ -291,7 +344,7 @@ class MemorySlice(BaseModel):
|
||||
children: list[str] = []
|
||||
for sc in self.scopes:
|
||||
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
|
||||
inf = self._memory.info(full)
|
||||
inf = self._require_memory().info(full)
|
||||
total_records += inf.record_count
|
||||
all_categories.update(inf.categories)
|
||||
if inf.oldest_record:
|
||||
@@ -321,6 +374,6 @@ class MemorySlice(BaseModel):
|
||||
counts: dict[str, int] = {}
|
||||
for sc in self.scopes:
|
||||
full = (f"{sc.rstrip('/')}{path}" if sc != "/" else path) if path else sc
|
||||
for k, v in self._memory.list_categories(full).items():
|
||||
for k, v in self._require_memory().list_categories(full).items():
|
||||
counts[k] = counts.get(k, 0) + v
|
||||
return counts
|
||||
|
||||
@@ -63,6 +63,8 @@ class Memory(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["memory"] = "memory"
|
||||
|
||||
llm: Annotated[BaseLLM | str, PlainValidator(_passthrough)] = Field(
|
||||
default="gpt-4o-mini",
|
||||
description="LLM for analysis (model name or BaseLLM instance).",
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
Provides filesystem-based skill packaging with progressive disclosure.
|
||||
"""
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import Skill, SkillFrontmatter
|
||||
from crewai.skills.parser import SkillParseError
|
||||
from crewai.skills.registry import is_registry_ref, resolve_registry_ref
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillCacheManager",
|
||||
"SkillFrontmatter",
|
||||
"SkillParseError",
|
||||
"activate_skill",
|
||||
"discover_skills",
|
||||
"is_registry_ref",
|
||||
"resolve_registry_ref",
|
||||
]
|
||||
|
||||
148
lib/crewai/src/crewai/skills/cache.py
Normal file
148
lib/crewai/src/crewai/skills/cache.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Cache manager for registry-downloaded skills.
|
||||
|
||||
Manages ~/.crewai/skills/{org}/{name}/ as the global skill cache.
|
||||
One version is stored per skill (last install wins).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
from typing import TypedDict
|
||||
import zipfile
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CACHE_ROOT = Path.home() / ".crewai" / "skills"
|
||||
_META_FILENAME = ".crewai_meta.json"
|
||||
|
||||
|
||||
class SkillMetadata(TypedDict):
|
||||
org: str
|
||||
name: str
|
||||
version: str | None
|
||||
installed_at: str
|
||||
|
||||
|
||||
class SkillCacheManager:
|
||||
"""Manages the global skill cache at ~/.crewai/skills/."""
|
||||
|
||||
def __init__(self, cache_root: Path | None = None) -> None:
|
||||
self._root = cache_root or _CACHE_ROOT
|
||||
|
||||
def _skill_dir(self, org: str, name: str) -> Path:
|
||||
return self._root / org / name
|
||||
|
||||
def get_cached_path(self, org: str, name: str) -> Path | None:
|
||||
"""Return the cached skill directory path if it exists, else None."""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
meta_file = skill_dir / _META_FILENAME
|
||||
if skill_dir.is_dir() and meta_file.exists():
|
||||
return skill_dir
|
||||
return None
|
||||
|
||||
def store(
|
||||
self, org: str, name: str, version: str | None, archive_bytes: bytes
|
||||
) -> Path:
|
||||
"""Unpack an archive into the cache and write metadata.
|
||||
|
||||
Uses tarfile with filter='data' for path-traversal protection.
|
||||
|
||||
Args:
|
||||
org: Organisation slug.
|
||||
name: Skill name.
|
||||
version: Semantic version string, or None if unknown.
|
||||
archive_bytes: Raw bytes of a .tar.gz archive.
|
||||
|
||||
Returns:
|
||||
Path to the stored skill directory.
|
||||
"""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
# Wipe any previous version
|
||||
if skill_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(skill_dir)
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import io
|
||||
|
||||
# Try tar.gz first, fall back to zip
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf:
|
||||
try:
|
||||
tf.extractall(skill_dir, filter="data")
|
||||
except TypeError:
|
||||
_safe_extractall(tf, skill_dir)
|
||||
except tarfile.TarError:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
|
||||
_safe_extract_zip(zf, skill_dir)
|
||||
|
||||
meta: SkillMetadata = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
(skill_dir / _META_FILENAME).write_text(json.dumps(meta, indent=2))
|
||||
return skill_dir
|
||||
|
||||
def list_cached(self) -> list[SkillMetadata]:
|
||||
"""Return metadata for every cached skill."""
|
||||
results: list[SkillMetadata] = []
|
||||
if not self._root.exists():
|
||||
return results
|
||||
for org_dir in sorted(self._root.iterdir()):
|
||||
if not org_dir.is_dir():
|
||||
continue
|
||||
for skill_dir in sorted(org_dir.iterdir()):
|
||||
meta_file = skill_dir / _META_FILENAME
|
||||
if meta_file.exists():
|
||||
try:
|
||||
results.append(json.loads(meta_file.read_text()))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
_logger.debug(
|
||||
"Skipping malformed cache entry: %s",
|
||||
meta_file,
|
||||
exc_info=True,
|
||||
)
|
||||
return results
|
||||
|
||||
def invalidate(self, org: str, name: str) -> bool:
|
||||
"""Remove a cached skill.
|
||||
|
||||
Returns:
|
||||
True if the cache entry existed and was removed, False otherwise.
|
||||
"""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
if skill_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(skill_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe ZIP extraction."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (dest / member).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member!r}")
|
||||
zf.extractall(dest) # noqa: S202
|
||||
@@ -78,6 +78,10 @@ class SkillFrontmatter(BaseModel):
|
||||
alias="allowed-tools",
|
||||
description="Pre-approved tool names the skill may use, parsed from a space-delimited string in frontmatter.",
|
||||
)
|
||||
version: str | None = Field(
|
||||
default=None,
|
||||
description="Semantic version of the skill, e.g. '1.0.0'. Optional for local skills.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
223
lib/crewai/src/crewai/skills/registry.py
Normal file
223
lib/crewai/src/crewai/skills/registry.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Registry reference resolution for the Agent Skills standard.
|
||||
|
||||
Handles @org/skill-name references, local-first resolution, and downloads
|
||||
via the CrewAI+ API with a global cache at ~/.crewai/skills/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillNotCachedError(Exception):
|
||||
"""Raised when a registry skill is not cached and the environment is non-interactive."""
|
||||
|
||||
def __init__(self, ref: str) -> None:
|
||||
super().__init__(
|
||||
f"Skill {ref!r} is not cached locally. "
|
||||
f"Run `crewai skill install {ref}` to install it first."
|
||||
)
|
||||
self.ref = ref
|
||||
|
||||
|
||||
def is_registry_ref(value: Any) -> bool:
|
||||
"""Return True if *value* looks like a registry reference (@org/name)."""
|
||||
return isinstance(value, str) and value.startswith("@")
|
||||
|
||||
|
||||
def parse_registry_ref(ref: str) -> tuple[str, str]:
|
||||
"""Parse '@org/skill-name' into (org, name).
|
||||
|
||||
Args:
|
||||
ref: A registry reference, e.g. '@acme/my-skill'.
|
||||
|
||||
Returns:
|
||||
A (org, name) tuple.
|
||||
|
||||
Raises:
|
||||
ValueError: If the reference format is invalid.
|
||||
"""
|
||||
if not ref.startswith("@"):
|
||||
raise ValueError(f"Registry reference must start with '@', got: {ref!r}")
|
||||
without_at = ref[1:]
|
||||
if without_at.count("/") != 1:
|
||||
raise ValueError(
|
||||
f"Registry reference must be in '@org/name' format, got: {ref!r}"
|
||||
)
|
||||
org, name = without_at.split("/", 1)
|
||||
if (
|
||||
not org
|
||||
or not name
|
||||
or org.startswith(".")
|
||||
or name.startswith(".")
|
||||
or "/" in org
|
||||
or "/" in name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Registry reference org and name must be single, non-empty path "
|
||||
f"segments (no '..' or leading dots), got: {ref!r}"
|
||||
)
|
||||
return org, name
|
||||
|
||||
|
||||
def _is_noninteractive() -> bool:
|
||||
"""Return True in CI or explicitly non-interactive environments."""
|
||||
import os
|
||||
|
||||
return (
|
||||
os.environ.get("CI") == "1"
|
||||
or os.environ.get("CREWAI_NONINTERACTIVE") == "1"
|
||||
or not sys.stdin.isatty()
|
||||
)
|
||||
|
||||
|
||||
def resolve_registry_ref(
|
||||
ref: str,
|
||||
source: Any = None,
|
||||
) -> Skill: # type: ignore[name-defined] # noqa: F821
|
||||
"""Resolve a registry reference to a Skill object.
|
||||
|
||||
Resolution order:
|
||||
1. ./skills/{name}/ in the current working directory (project-local)
|
||||
2. ~/.crewai/skills/{org}/{name}/ (global cache)
|
||||
3. Download from registry (interactive only; raises SkillNotCachedError in CI)
|
||||
|
||||
Args:
|
||||
ref: A registry reference, e.g. '@acme/my-skill'.
|
||||
source: Optional source object passed through to skill loaders (for events).
|
||||
|
||||
Returns:
|
||||
A Skill loaded at INSTRUCTIONS disclosure level.
|
||||
|
||||
Raises:
|
||||
SkillNotCachedError: When not cached and running in non-interactive mode.
|
||||
"""
|
||||
from crewai.skills.loader import activate_skill
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
org, name = parse_registry_ref(ref)
|
||||
|
||||
# 1. Project-local: ./skills/{name}/
|
||||
local_path = Path.cwd() / "skills" / name
|
||||
if local_path.is_dir() and (local_path / "SKILL.md").exists():
|
||||
try:
|
||||
skill = load_skill_metadata(local_path)
|
||||
return activate_skill(skill, source=source)
|
||||
except Exception:
|
||||
_logger.debug("Failed to load local skill at %s", local_path, exc_info=True)
|
||||
|
||||
# 2. Global cache
|
||||
cache = SkillCacheManager()
|
||||
cached_path = cache.get_cached_path(org, name)
|
||||
if cached_path is not None and (cached_path / "SKILL.md").exists():
|
||||
try:
|
||||
skill = load_skill_metadata(cached_path)
|
||||
return activate_skill(skill, source=source)
|
||||
except Exception:
|
||||
_logger.debug(
|
||||
"Failed to load cached skill at %s", cached_path, exc_info=True
|
||||
)
|
||||
|
||||
# 3. Download
|
||||
if _is_noninteractive():
|
||||
raise SkillNotCachedError(ref)
|
||||
|
||||
return download_skill(org, name, source=source)
|
||||
|
||||
|
||||
def download_skill(
|
||||
org: str,
|
||||
name: str,
|
||||
source: Any = None,
|
||||
) -> Skill: # type: ignore[name-defined] # noqa: F821
|
||||
"""Download a skill from the registry and store it in the cache.
|
||||
|
||||
Args:
|
||||
org: Organisation slug.
|
||||
name: Skill name.
|
||||
source: Optional source for event emission.
|
||||
|
||||
Returns:
|
||||
The downloaded Skill at INSTRUCTIONS level.
|
||||
"""
|
||||
from crewai.skills.loader import activate_skill
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
ref = f"@{org}/{name}"
|
||||
|
||||
try:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.skill_events import (
|
||||
SkillDownloadCompletedEvent,
|
||||
SkillDownloadStartedEvent,
|
||||
)
|
||||
|
||||
_has_events = True
|
||||
except ImportError:
|
||||
_has_events = False
|
||||
|
||||
if _has_events:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDownloadStartedEvent(
|
||||
registry_ref=ref,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
from crewai_core.plus_api import PlusAPI
|
||||
|
||||
api = PlusAPI()
|
||||
response = api.get_skill(org, name)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to download skill {ref!r} from registry: {exc}"
|
||||
) from exc
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
||||
version = data.get("latest_version") or data.get("version")
|
||||
|
||||
download_url = data.get("download_url")
|
||||
if download_url:
|
||||
dl_response = httpx.get(download_url, follow_redirects=True)
|
||||
dl_response.raise_for_status()
|
||||
archive_bytes = dl_response.content
|
||||
else:
|
||||
encoded = data.get("file", "")
|
||||
# Strip data URI prefix if present
|
||||
if "," in encoded:
|
||||
encoded = encoded.split(",", 1)[1]
|
||||
archive_bytes = base64.b64decode(encoded)
|
||||
|
||||
cache = SkillCacheManager()
|
||||
skill_dir = cache.store(org, name, version, archive_bytes)
|
||||
|
||||
if _has_events:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDownloadCompletedEvent(
|
||||
registry_ref=ref,
|
||||
version=version,
|
||||
cache_path=skill_dir,
|
||||
),
|
||||
)
|
||||
|
||||
if not (skill_dir / "SKILL.md").exists():
|
||||
raise RuntimeError(
|
||||
f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}"
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
return activate_skill(skill, source=source)
|
||||
@@ -113,12 +113,68 @@ def _migrate(data: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
# --- migrations in version order ---
|
||||
# if stored < Version("X.Y.Z"):
|
||||
# data.setdefault("some_field", "default")
|
||||
if stored < Version("1.14.6"):
|
||||
for entity in data.get("entities") or []:
|
||||
_backfill_discriminators(entity)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _backfill_memory_kind(value: Any) -> None:
|
||||
"""Infer ``memory_kind`` from structural fields on legacy memory dicts."""
|
||||
if not isinstance(value, dict) or "memory_kind" in value:
|
||||
return
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
value["memory_kind"] = "scope"
|
||||
else:
|
||||
value["memory_kind"] = "memory"
|
||||
|
||||
|
||||
def _backfill_source_type(source: Any) -> None:
|
||||
"""Infer ``source_type`` for legacy knowledge source dicts when possible.
|
||||
|
||||
Only StringKnowledgeSource is reliably inferrable: it stores ``content``
|
||||
as a plain string. File-based sources (CSV/PDF/Excel/JSON/docling) also
|
||||
have a ``content`` field but populate it with dicts/lists, so we leave
|
||||
those untagged and let downstream validation surface a clear error.
|
||||
"""
|
||||
if not isinstance(source, dict) or "source_type" in source:
|
||||
return
|
||||
if isinstance(source.get("content"), str):
|
||||
source["source_type"] = "string"
|
||||
return
|
||||
raise ValueError(
|
||||
"Legacy knowledge source is missing 'source_type' and could not be "
|
||||
"inferred during migration. Re-checkpoint after upgrading to 1.14.6+."
|
||||
)
|
||||
|
||||
|
||||
def _backfill_sources_on(container: Any) -> None:
|
||||
"""Apply source_type backfill to ``sources`` and ``knowledge_sources`` lists."""
|
||||
if not isinstance(container, dict):
|
||||
return
|
||||
for key in ("sources", "knowledge_sources"):
|
||||
for src in container.get(key) or []:
|
||||
_backfill_source_type(src)
|
||||
|
||||
|
||||
def _backfill_discriminators(entity: Any) -> None:
|
||||
"""Walk an entity dict and backfill discriminator fields added in 1.14.6."""
|
||||
if not isinstance(entity, dict):
|
||||
return
|
||||
_backfill_memory_kind(entity.get("memory"))
|
||||
_backfill_sources_on(entity)
|
||||
_backfill_sources_on(entity.get("knowledge"))
|
||||
for agent in entity.get("agents") or []:
|
||||
if not isinstance(agent, dict):
|
||||
continue
|
||||
_backfill_memory_kind(agent.get("memory"))
|
||||
_backfill_sources_on(agent)
|
||||
_backfill_sources_on(agent.get("knowledge"))
|
||||
|
||||
|
||||
class RuntimeState(RootModel): # type: ignore[type-arg]
|
||||
root: list[Entity]
|
||||
_provider: BaseProvider = PrivateAttr(default_factory=JsonProvider)
|
||||
|
||||
@@ -19,6 +19,15 @@ from pydantic import BeforeValidator, WithJsonSchema
|
||||
from pydantic.functional_serializers import PlainSerializer
|
||||
|
||||
|
||||
_TRUSTED_DESERIALIZE_VALUES = frozenset({"1", "true", "yes"})
|
||||
|
||||
|
||||
def _trusted_deserialize() -> bool:
|
||||
"""Return True only if ``CREWAI_DESERIALIZE_CALLBACKS`` is an explicit yes."""
|
||||
raw = os.environ.get("CREWAI_DESERIALIZE_CALLBACKS", "")
|
||||
return raw.strip().lower() in _TRUSTED_DESERIALIZE_VALUES
|
||||
|
||||
|
||||
def _is_non_roundtrippable(fn: object) -> bool:
|
||||
"""Return ``True`` if *fn* cannot survive a serialize/deserialize round-trip.
|
||||
|
||||
@@ -76,7 +85,7 @@ def string_to_callable(value: Any) -> Callable[..., Any]:
|
||||
raise ValueError(
|
||||
f"Invalid callback path {value!r}: expected 'module.name' format"
|
||||
)
|
||||
if not os.environ.get("CREWAI_DESERIALIZE_CALLBACKS"):
|
||||
if not _trusted_deserialize():
|
||||
raise ValueError(
|
||||
f"Refusing to resolve callback path {value!r}: "
|
||||
"set CREWAI_DESERIALIZE_CALLBACKS=1 to allow. "
|
||||
@@ -150,3 +159,78 @@ SerializableCallable = Annotated[
|
||||
PlainSerializer(callable_to_string, return_type=str, when_used="json"),
|
||||
WithJsonSchema({"type": "string"}),
|
||||
]
|
||||
|
||||
|
||||
def _instance_to_dotted_path(value: Any) -> str:
|
||||
"""Serialize an instance to a dotted path naming its class."""
|
||||
if inspect.isclass(value):
|
||||
module = getattr(value, "__module__", "<unknown>")
|
||||
qualname = getattr(
|
||||
value, "__qualname__", getattr(value, "__name__", str(type(value)))
|
||||
)
|
||||
raise ValueError(f"Expected an instance, got class {module}.{qualname}.")
|
||||
cls = type(value)
|
||||
if cls.__module__ == "builtins":
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: builtin values are not "
|
||||
"checkpointable instances."
|
||||
)
|
||||
module = getattr(cls, "__module__", None)
|
||||
qualname = getattr(cls, "__qualname__", None)
|
||||
if module is None or qualname is None:
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: class missing __module__ or __qualname__. "
|
||||
"Use a module-level class for checkpointable instances."
|
||||
)
|
||||
if qualname.endswith("<lambda>") or "<locals>" in qualname:
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: class defined in <locals>. "
|
||||
"Use a module-level class for checkpointable instances."
|
||||
)
|
||||
return f"{module}.{qualname}"
|
||||
|
||||
|
||||
def _dotted_path_to_instance(value: Any) -> Any:
|
||||
"""Resolve a dotted path to a class and instantiate it with no args.
|
||||
|
||||
If *value* is already a non-string object it is returned as-is.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
if inspect.isclass(value):
|
||||
raise ValueError(
|
||||
f"Expected an instance or dotted path string, got class "
|
||||
f"{getattr(value, '__module__', '<unknown>')}."
|
||||
f"{getattr(value, '__qualname__', getattr(value, '__name__', ''))}."
|
||||
)
|
||||
if type(value).__module__ == "builtins":
|
||||
raise ValueError(
|
||||
f"Expected an instance of a user-defined class or dotted "
|
||||
f"path string, got builtin value {value!r}."
|
||||
)
|
||||
return value
|
||||
if "." not in value:
|
||||
raise ValueError(
|
||||
f"Invalid provider path {value!r}: expected 'module.name' format"
|
||||
)
|
||||
if not _trusted_deserialize():
|
||||
raise ValueError(
|
||||
f"Refusing to resolve provider path {value!r}: "
|
||||
"set CREWAI_DESERIALIZE_CALLBACKS=1 to allow. "
|
||||
"Only enable this for trusted checkpoint data."
|
||||
)
|
||||
cls = _resolve_dotted_path(value)
|
||||
if not inspect.isclass(cls):
|
||||
raise ValueError(
|
||||
f"Invalid provider path {value!r}: expected a class, got "
|
||||
f"{type(cls).__name__}"
|
||||
)
|
||||
try:
|
||||
return cls()
|
||||
except TypeError as exc:
|
||||
raise ValueError(
|
||||
f"Cannot reinstantiate {value!r} with no arguments: {exc}. "
|
||||
"Only no-arg constructors are checkpointable; rebuild the "
|
||||
"instance manually and assign it after restore."
|
||||
) from exc
|
||||
|
||||
@@ -13,6 +13,7 @@ import sys
|
||||
import types
|
||||
from typing import Any, cast, get_type_hints
|
||||
|
||||
from crewai_core.plus_api import AvailableExport, EnvVarEntry, ToolMetadata
|
||||
from crewai_core.project import (
|
||||
get_project_description as get_project_description,
|
||||
get_project_name as get_project_name,
|
||||
@@ -279,7 +280,7 @@ def is_valid_tool(obj: Any) -> bool:
|
||||
return isinstance(obj, Tool)
|
||||
|
||||
|
||||
def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
def extract_available_exports(dir_path: str = "src") -> list[AvailableExport]:
|
||||
"""Extract available tool classes from the project's __init__.py files.
|
||||
|
||||
Only includes classes that inherit from BaseTool or functions decorated with @tool.
|
||||
@@ -338,7 +339,7 @@ def _load_module_from_file(
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
|
||||
def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
def _load_tools_from_init(init_file: Path) -> list[AvailableExport]:
|
||||
"""Load and validate tools from a given __init__.py file."""
|
||||
try:
|
||||
with _load_module_from_file(init_file) as module:
|
||||
@@ -392,7 +393,7 @@ def _print_no_tools_warning() -> None:
|
||||
)
|
||||
|
||||
|
||||
def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
def extract_tools_metadata(dir_path: str = "src") -> list[ToolMetadata]:
|
||||
"""
|
||||
Extract rich metadata from tool classes in the project.
|
||||
|
||||
@@ -404,7 +405,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
- init_params_schema: JSON Schema for __init__ params (filtered)
|
||||
- env_vars: List of environment variable dicts
|
||||
"""
|
||||
tools_metadata: list[dict[str, Any]] = []
|
||||
tools_metadata: list[ToolMetadata] = []
|
||||
|
||||
for init_file in Path(dir_path).glob("**/__init__.py"):
|
||||
tools = _extract_tool_metadata_from_init(init_file)
|
||||
@@ -413,7 +414,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
|
||||
return tools_metadata
|
||||
|
||||
|
||||
def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
def _extract_tool_metadata_from_init(init_file: Path) -> list[ToolMetadata]:
|
||||
"""
|
||||
Load module from init file and extract metadata from valid tool classes.
|
||||
"""
|
||||
@@ -428,7 +429,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
if not exported_names:
|
||||
return []
|
||||
|
||||
tools_metadata = []
|
||||
tools_metadata: list[ToolMetadata] = []
|
||||
for name in exported_names:
|
||||
obj = getattr(module, name, None)
|
||||
if obj is None or not (
|
||||
@@ -446,7 +447,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None:
|
||||
def _extract_single_tool_metadata(tool_class: type) -> ToolMetadata | None:
|
||||
"""
|
||||
Extract metadata from a single tool class.
|
||||
"""
|
||||
@@ -470,19 +471,17 @@ def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None:
|
||||
except (TypeError, ValueError):
|
||||
module = tool_class.__module__
|
||||
|
||||
return {
|
||||
"name": tool_class.__name__,
|
||||
"module": module,
|
||||
"humanized_name": _extract_field_default(
|
||||
fields.get("name"), fallback=tool_class.__name__
|
||||
return ToolMetadata(
|
||||
name=tool_class.__name__,
|
||||
module=module,
|
||||
humanized_name=str(
|
||||
_extract_field_default(fields.get("name"), fallback=tool_class.__name__)
|
||||
),
|
||||
"description": str(
|
||||
_extract_field_default(fields.get("description"))
|
||||
).strip(),
|
||||
"run_params_schema": _extract_run_params_schema(fields.get("args_schema")),
|
||||
"init_params_schema": _extract_init_params_schema(tool_class),
|
||||
"env_vars": _extract_env_vars(fields.get("env_vars")),
|
||||
}
|
||||
description=str(_extract_field_default(fields.get("description"))).strip(),
|
||||
run_params_schema=_extract_run_params_schema(fields.get("args_schema")),
|
||||
init_params_schema=_extract_init_params_schema(tool_class),
|
||||
env_vars=_extract_env_vars(fields.get("env_vars")),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
@@ -597,7 +596,7 @@ def _extract_init_params_schema(tool_class: type) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[EnvVarEntry]:
|
||||
"""
|
||||
Extract environment variable definitions from env_vars field.
|
||||
"""
|
||||
|
||||
@@ -25,10 +25,16 @@ def _reset_flow_memory(flow: Flow[Any]) -> None:
|
||||
try:
|
||||
if hasattr(mem, "reset"):
|
||||
mem.reset()
|
||||
elif hasattr(mem, "_memory") and hasattr(mem._memory, "reset"):
|
||||
elif hasattr(mem, "_memory") and mem._memory is not None:
|
||||
mem._memory.reset()
|
||||
except (FileNotFoundError, OSError):
|
||||
except FileNotFoundError:
|
||||
# Storage directory was never created — nothing to reset.
|
||||
pass
|
||||
except OSError as exc:
|
||||
click.echo(f"Memory reset skipped: storage I/O error ({exc}).", err=True)
|
||||
except RuntimeError as exc:
|
||||
# Restored MemoryScope/MemorySlice without a rebound Memory.
|
||||
click.echo(f"Memory reset skipped: {exc}", err=True)
|
||||
|
||||
|
||||
def reset_memories_command(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Tests for A2A delegation tool behavior, including trust_remote_completion_status."""
|
||||
"""Test trust_remote_completion_status flag in A2A wrapper."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.a2a.config import A2AClientConfig, A2AConfig
|
||||
|
||||
from crewai.a2a.config import A2AConfig
|
||||
|
||||
try:
|
||||
from a2a.types import TaskState # noqa: F401
|
||||
from a2a.types import Message, Role
|
||||
|
||||
A2A_SDK_INSTALLED = True
|
||||
except ImportError:
|
||||
@@ -16,126 +15,141 @@ except ImportError:
|
||||
|
||||
|
||||
def _create_mock_agent_card(name: str = "Test", url: str = "http://test-endpoint.com/"):
|
||||
"""Create a mock agent card with the attributes A2ADelegationTool reads."""
|
||||
"""Create a mock agent card with proper model_dump behavior."""
|
||||
mock_card = MagicMock()
|
||||
mock_card.name = name
|
||||
mock_card.url = url
|
||||
mock_card.description = "A test agent"
|
||||
mock_card.skills = []
|
||||
mock_card.model_dump.return_value = {"name": name, "url": url}
|
||||
mock_card.model_dump_json.return_value = f'{{"name": "{name}", "url": "{url}"}}'
|
||||
return mock_card
|
||||
|
||||
|
||||
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
|
||||
def test_delegation_tool_returns_remote_result_on_completion():
|
||||
"""A successful remote completion is returned to the local LLM as the tool result."""
|
||||
from a2a.types import TaskState
|
||||
|
||||
def test_trust_remote_completion_status_true_returns_directly():
|
||||
"""When trust_remote_completion_status=True and A2A returns completed, return result directly."""
|
||||
from crewai.a2a.wrapper import _delegate_to_a2a
|
||||
from crewai.a2a.types import AgentResponseProtocol
|
||||
from crewai import Agent, Task
|
||||
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
|
||||
|
||||
config = A2AClientConfig(endpoint="http://test-endpoint.com")
|
||||
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
|
||||
a2a_config = A2AConfig(
|
||||
endpoint="http://test-endpoint.com",
|
||||
trust_remote_completion_status=True,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
role="test manager",
|
||||
goal="coordinate",
|
||||
backstory="test",
|
||||
a2a=a2a_config,
|
||||
)
|
||||
|
||||
task = Task(description="test", expected_output="test", agent=agent)
|
||||
|
||||
card = _create_mock_agent_card()
|
||||
state = A2ADelegationState(agent=agent, task=task)
|
||||
tools = build_a2a_tools([config], {config.endpoint: card}, state)
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
class MockResponse:
|
||||
is_a2a = True
|
||||
message = "Please help"
|
||||
a2a_ids = ["http://test-endpoint.com/"]
|
||||
|
||||
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
|
||||
with (
|
||||
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
|
||||
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
|
||||
):
|
||||
mock_card = _create_mock_agent_card()
|
||||
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
|
||||
|
||||
# A2A returns completed
|
||||
mock_execute.return_value = {
|
||||
"status": TaskState.completed,
|
||||
"status": "completed",
|
||||
"result": "Done by remote",
|
||||
"history": [],
|
||||
}
|
||||
result = tool._run(message="Please help")
|
||||
|
||||
assert result == "Done by remote"
|
||||
assert mock_execute.call_count == 1
|
||||
# This should return directly without checking LLM response
|
||||
result = _delegate_to_a2a(
|
||||
self=agent,
|
||||
agent_response=MockResponse(),
|
||||
task=task,
|
||||
original_fn=lambda *args, **kwargs: "fallback",
|
||||
context=None,
|
||||
tools=None,
|
||||
agent_cards={"http://test-endpoint.com/": mock_card},
|
||||
original_task_description="test",
|
||||
)
|
||||
|
||||
assert result == "Done by remote"
|
||||
assert mock_execute.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
|
||||
def test_delegation_tool_records_completed_task_in_references():
|
||||
"""When a remote task completes with a task_id, it goes into reference_task_ids."""
|
||||
from a2a.types import TaskState
|
||||
|
||||
def test_trust_remote_completion_status_false_continues_conversation():
|
||||
"""When trust_remote_completion_status=False and A2A returns completed, ask server agent."""
|
||||
from crewai.a2a.wrapper import _delegate_to_a2a
|
||||
from crewai import Agent, Task
|
||||
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
|
||||
|
||||
config = A2AClientConfig(endpoint="http://test-endpoint.com")
|
||||
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
|
||||
a2a_config = A2AConfig(
|
||||
endpoint="http://test-endpoint.com",
|
||||
trust_remote_completion_status=False,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
role="test manager",
|
||||
goal="coordinate",
|
||||
backstory="test",
|
||||
a2a=a2a_config,
|
||||
)
|
||||
|
||||
task = Task(description="test", expected_output="test", agent=agent)
|
||||
|
||||
card = _create_mock_agent_card()
|
||||
state = A2ADelegationState(agent=agent, task=task)
|
||||
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
|
||||
class MockResponse:
|
||||
is_a2a = True
|
||||
message = "Please help"
|
||||
a2a_ids = ["http://test-endpoint.com/"]
|
||||
|
||||
history_msg = MagicMock()
|
||||
history_msg.task_id = "remote-task-1"
|
||||
history_msg.context_id = "ctx-1"
|
||||
call_count = 0
|
||||
|
||||
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
|
||||
def mock_original_fn(self, task, context, tools):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# Server decides to finish
|
||||
return '{"is_a2a": false, "message": "Server final answer", "a2a_ids": []}'
|
||||
return "unexpected"
|
||||
|
||||
with (
|
||||
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
|
||||
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
|
||||
):
|
||||
mock_card = _create_mock_agent_card()
|
||||
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
|
||||
|
||||
# A2A returns completed
|
||||
mock_execute.return_value = {
|
||||
"status": TaskState.completed,
|
||||
"result": "Done",
|
||||
"history": [history_msg],
|
||||
}
|
||||
tool._run(message="Please help")
|
||||
|
||||
endpoint_state = state._per_endpoint[config.endpoint]
|
||||
assert "remote-task-1" in endpoint_state.reference_task_ids
|
||||
assert endpoint_state.task_id is None
|
||||
assert task.config is not None
|
||||
assert task.config["reference_task_ids"] == ["remote-task-1"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
|
||||
def test_delegation_tool_returns_error_message_on_failure():
|
||||
"""A non-completed/non-input-required status surfaces as a readable error string."""
|
||||
from a2a.types import TaskState
|
||||
|
||||
from crewai import Agent, Task
|
||||
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
|
||||
|
||||
config = A2AClientConfig(endpoint="http://test-endpoint.com")
|
||||
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
|
||||
task = Task(description="test", expected_output="test", agent=agent)
|
||||
|
||||
card = _create_mock_agent_card()
|
||||
state = A2ADelegationState(agent=agent, task=task)
|
||||
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
|
||||
|
||||
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
|
||||
mock_execute.return_value = {
|
||||
"status": TaskState.failed,
|
||||
"error": "remote agent unreachable",
|
||||
"status": "completed",
|
||||
"result": "Done by remote",
|
||||
"history": [],
|
||||
}
|
||||
result = tool._run(message="Please help")
|
||||
|
||||
assert "remote agent unreachable" in result
|
||||
result = _delegate_to_a2a(
|
||||
self=agent,
|
||||
agent_response=MockResponse(),
|
||||
task=task,
|
||||
original_fn=mock_original_fn,
|
||||
context=None,
|
||||
tools=None,
|
||||
agent_cards={"http://test-endpoint.com/": mock_card},
|
||||
original_task_description="test",
|
||||
)
|
||||
|
||||
# Should call original_fn to get server response
|
||||
assert call_count >= 1
|
||||
assert result == "Server final answer"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
|
||||
def test_delegation_tool_respects_max_turns_via_usage_count():
|
||||
"""A2AConfig.max_turns wires through to BaseTool.max_usage_count."""
|
||||
from crewai import Agent, Task
|
||||
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
|
||||
|
||||
config = A2AClientConfig(endpoint="http://test-endpoint.com", max_turns=2)
|
||||
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
|
||||
task = Task(description="test", expected_output="test", agent=agent)
|
||||
|
||||
card = _create_mock_agent_card()
|
||||
state = A2ADelegationState(agent=agent, task=task)
|
||||
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
|
||||
|
||||
assert tool.max_usage_count == 2
|
||||
|
||||
|
||||
def test_default_trust_remote_completion_status_is_false():
|
||||
"""Verify that default value of trust_remote_completion_status is False."""
|
||||
a2a_config = A2AConfig(endpoint="http://test-endpoint.com")
|
||||
a2a_config = A2AConfig(
|
||||
endpoint="http://test-endpoint.com",
|
||||
)
|
||||
|
||||
assert a2a_config.trust_remote_completion_status is False
|
||||
116
lib/crewai/tests/skills/test_cache.py
Normal file
116
lib/crewai/tests/skills/test_cache.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for SkillCacheManager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
def _make_tar_gz(files: dict[str, str]) -> bytes:
|
||||
"""Build an in-memory .tar.gz containing the given filename → content mapping."""
|
||||
buf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
|
||||
gz_buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=gz_buf, mode="w") as tf:
|
||||
for name, content in files.items():
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
gz.write(gz_buf.getvalue())
|
||||
buf.seek(0)
|
||||
# Re-create properly: gzip wrapping a tar stream
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode="w:gz") as tf:
|
||||
for name, content in files.items():
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class TestSkillCacheManager:
|
||||
def test_get_cached_path_missing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
assert cache.get_cached_path("acme", "my-skill") is None
|
||||
|
||||
def test_store_and_retrieve(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
|
||||
dest = cache.store("acme", "my-skill", "1.0.0", archive)
|
||||
|
||||
assert dest.is_dir()
|
||||
assert (dest / "SKILL.md").exists()
|
||||
|
||||
retrieved = cache.get_cached_path("acme", "my-skill")
|
||||
assert retrieved == dest
|
||||
|
||||
def test_store_writes_metadata(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "content"})
|
||||
dest = cache.store("acme", "my-skill", "2.3.4", archive)
|
||||
|
||||
meta_file = dest / ".crewai_meta.json"
|
||||
assert meta_file.exists()
|
||||
meta = json.loads(meta_file.read_text())
|
||||
assert meta["org"] == "acme"
|
||||
assert meta["name"] == "my-skill"
|
||||
assert meta["version"] == "2.3.4"
|
||||
assert "installed_at" in meta
|
||||
|
||||
def test_store_overwrites_previous_version(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive_v1 = _make_tar_gz({"SKILL.md": "v1", "extra.txt": "old"})
|
||||
cache.store("acme", "my-skill", "1.0.0", archive_v1)
|
||||
|
||||
archive_v2 = _make_tar_gz({"SKILL.md": "v2"})
|
||||
dest = cache.store("acme", "my-skill", "2.0.0", archive_v2)
|
||||
|
||||
# Old file should be gone
|
||||
assert not (dest / "extra.txt").exists()
|
||||
assert (dest / "SKILL.md").read_text() == "v2"
|
||||
|
||||
meta = json.loads((dest / ".crewai_meta.json").read_text())
|
||||
assert meta["version"] == "2.0.0"
|
||||
|
||||
def test_list_cached_empty(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
assert cache.list_cached() == []
|
||||
|
||||
def test_list_cached(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
cache.store("acme", "skill-a", "1.0.0", archive)
|
||||
cache.store("acme", "skill-b", "0.1.0", archive)
|
||||
cache.store("other-org", "skill-c", None, archive)
|
||||
|
||||
entries = cache.list_cached()
|
||||
names = {e["name"] for e in entries}
|
||||
assert names == {"skill-a", "skill-b", "skill-c"}
|
||||
|
||||
def test_invalidate_existing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
cache.store("acme", "my-skill", "1.0.0", archive)
|
||||
|
||||
removed = cache.invalidate("acme", "my-skill")
|
||||
assert removed is True
|
||||
assert cache.get_cached_path("acme", "my-skill") is None
|
||||
|
||||
def test_invalidate_missing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
removed = cache.invalidate("acme", "ghost-skill")
|
||||
assert removed is False
|
||||
|
||||
def test_store_version_none(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
dest = cache.store("acme", "my-skill", None, archive)
|
||||
meta = json.loads((dest / ".crewai_meta.json").read_text())
|
||||
assert meta["version"] is None
|
||||
32
lib/crewai/tests/skills/test_models_version.py
Normal file
32
lib/crewai/tests/skills/test_models_version.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for the version field added to SkillFrontmatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from crewai.skills.models import SkillFrontmatter
|
||||
|
||||
|
||||
class TestSkillFrontmatterVersion:
|
||||
def test_version_defaults_to_none(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_can_be_set(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.2.3")
|
||||
assert fm.version == "1.2.3"
|
||||
|
||||
def test_existing_frontmatter_without_version_still_valid(self) -> None:
|
||||
"""Backward compat: existing SKILL.md files without version must still parse."""
|
||||
fm = SkillFrontmatter(name="old-skill", description="Old skill without version.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_is_optional_string(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="Desc.", version=None)
|
||||
assert fm.version is None
|
||||
|
||||
def test_frontmatter_is_frozen(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.0.0")
|
||||
with pytest.raises(ValidationError):
|
||||
fm.version = "2.0.0" # type: ignore[misc]
|
||||
129
lib/crewai/tests/skills/test_registry.py
Normal file
129
lib/crewai/tests/skills/test_registry.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for SkillRegistry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.registry import (
|
||||
SkillNotCachedError,
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
)
|
||||
|
||||
|
||||
class TestIsRegistryRef:
|
||||
def test_at_prefixed(self) -> None:
|
||||
assert is_registry_ref("@acme/my-skill") is True
|
||||
|
||||
def test_plain_string(self) -> None:
|
||||
assert is_registry_ref("my-skill") is False
|
||||
|
||||
def test_path_like_string(self) -> None:
|
||||
assert is_registry_ref("./skills/my-skill") is False
|
||||
|
||||
def test_non_string(self) -> None:
|
||||
assert is_registry_ref(None) is False
|
||||
assert is_registry_ref(42) is False
|
||||
assert is_registry_ref(Path("something")) is False
|
||||
|
||||
|
||||
class TestParseRegistryRef:
|
||||
def test_valid(self) -> None:
|
||||
assert parse_registry_ref("@acme/my-skill") == ("acme", "my-skill")
|
||||
|
||||
def test_valid_with_dashes(self) -> None:
|
||||
assert parse_registry_ref("@my-org/cool-skill") == ("my-org", "cool-skill")
|
||||
|
||||
def test_missing_at(self) -> None:
|
||||
with pytest.raises(ValueError, match="must start with '@'"):
|
||||
parse_registry_ref("acme/my-skill")
|
||||
|
||||
def test_missing_slash(self) -> None:
|
||||
with pytest.raises(ValueError, match="'@org/name' format"):
|
||||
parse_registry_ref("@acme-skill")
|
||||
|
||||
def test_empty_org(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
parse_registry_ref("@/my-skill")
|
||||
|
||||
def test_empty_name(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
parse_registry_ref("@acme/")
|
||||
|
||||
|
||||
class TestResolveRegistryRef:
|
||||
"""Test resolution order and CI mode behaviour."""
|
||||
|
||||
def _make_skill_dir(self, base: Path, name: str) -> Path:
|
||||
"""Write a minimal SKILL.md into base/name/."""
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Test skill.\n---\n\nInstructions."
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
def test_resolves_project_local(self, tmp_path: Path) -> None:
|
||||
"""Local ./skills/{name}/ takes priority over cache."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
self._make_skill_dir(skills_dir, "my-skill")
|
||||
|
||||
# Mock SkillCacheManager to return None (not cached) so only local is hit
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/my-skill")
|
||||
|
||||
assert skill.name == "my-skill"
|
||||
|
||||
def test_raises_in_ci_when_not_cached(self, tmp_path: Path) -> None:
|
||||
"""In CI mode, raise SkillNotCachedError if no local or cached copy."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=True),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
with pytest.raises(SkillNotCachedError) as exc_info:
|
||||
resolve_registry_ref("@acme/ghost-skill")
|
||||
assert "@acme/ghost-skill" in str(exc_info.value)
|
||||
|
||||
def test_resolves_from_cache(self, tmp_path: Path) -> None:
|
||||
"""Falls back to global cache when no project-local skill exists."""
|
||||
cache_dir = tmp_path / "acme" / "cached-skill"
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / "SKILL.md").write_text(
|
||||
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
|
||||
)
|
||||
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = cache_dir
|
||||
|
||||
# tmp_path has no ./skills/ directory
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/cached-skill")
|
||||
|
||||
assert skill.name == "cached-skill"
|
||||
|
||||
def test_skill_not_cached_error_contains_ref(self) -> None:
|
||||
err = SkillNotCachedError("@foo/bar")
|
||||
assert "@foo/bar" in str(err)
|
||||
assert err.ref == "@foo/bar"
|
||||
@@ -7,20 +7,87 @@ durability, input history tracking, and integration with flow machinery.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.flow import Flow, flow_config, listen, start
|
||||
from crewai.flow.async_feedback.providers import ConsoleProvider
|
||||
from crewai.flow.flow import FlowState
|
||||
from crewai.flow.input_provider import InputProvider, InputResponse
|
||||
from crewai.flow.persistence.base import FlowPersistence
|
||||
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _SaveCall:
|
||||
"""Lightweight stand-in for ``MagicMock.call_args`` entries."""
|
||||
|
||||
__slots__ = ("args", "kwargs")
|
||||
|
||||
def __init__(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class _SaveStateRecorder:
|
||||
"""Callable that records each ``save_state`` invocation."""
|
||||
|
||||
def __init__(self, owner: RecordingPersistence) -> None:
|
||||
self._owner = owner
|
||||
self.call_args_list: list[_SaveCall] = []
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
flow_uuid: str,
|
||||
method_name: str,
|
||||
state_data: dict[str, Any] | BaseModel,
|
||||
) -> None:
|
||||
snapshot: dict[str, Any] | BaseModel
|
||||
if isinstance(state_data, BaseModel):
|
||||
snapshot = state_data.model_copy(deep=True)
|
||||
else:
|
||||
snapshot = copy.deepcopy(state_data)
|
||||
self.call_args_list.append(
|
||||
_SaveCall((flow_uuid, method_name, snapshot), {})
|
||||
)
|
||||
self._owner._states[flow_uuid] = snapshot
|
||||
|
||||
|
||||
class RecordingPersistence(FlowPersistence):
|
||||
"""In-memory FlowPersistence that records ``save_state`` invocations."""
|
||||
|
||||
persistence_type: str = "RecordingPersistence"
|
||||
|
||||
def model_post_init(self, _: Any) -> None:
|
||||
object.__setattr__(self, "_states", {})
|
||||
object.__setattr__(self, "save_state", _SaveStateRecorder(self))
|
||||
|
||||
def init_db(self) -> None:
|
||||
return None
|
||||
|
||||
def save_state( # type: ignore[no-redef]
|
||||
self,
|
||||
flow_uuid: str,
|
||||
method_name: str,
|
||||
state_data: dict[str, Any] | BaseModel,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def load_state(self, flow_uuid: str) -> dict[str, Any] | None:
|
||||
snapshot = self._states.get(flow_uuid)
|
||||
if snapshot is None:
|
||||
return None
|
||||
if isinstance(snapshot, BaseModel):
|
||||
return snapshot.model_copy(deep=True).model_dump()
|
||||
return copy.deepcopy(snapshot)
|
||||
|
||||
|
||||
class MockInputProvider:
|
||||
"""Mock input provider that returns pre-configured responses."""
|
||||
|
||||
@@ -436,8 +503,7 @@ class TestAskCheckpoint:
|
||||
|
||||
def test_ask_checkpoints_state_before_waiting(self) -> None:
|
||||
"""State is saved to persistence before waiting for input."""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class TestFlow(Flow):
|
||||
input_provider = MockInputProvider(["answer"])
|
||||
@@ -480,8 +546,7 @@ class TestAskCheckpoint:
|
||||
server crashes while waiting for input, previously gathered data
|
||||
is safe.
|
||||
"""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class GatherFlow(Flow):
|
||||
input_provider = MockInputProvider(["AI", "detailed"])
|
||||
@@ -678,8 +743,7 @@ class TestAskIntegration:
|
||||
|
||||
def test_ask_with_state_persistence_recovery(self) -> None:
|
||||
"""Ask checkpoints state so previously gathered values survive."""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class RecoverableFlow(Flow):
|
||||
input_provider = MockInputProvider(["AI", "detailed"])
|
||||
|
||||
@@ -624,12 +624,15 @@ def test_handle_streaming_tool_calls_no_available_functions(
|
||||
],
|
||||
tools=[get_weather_tool_schema],
|
||||
)
|
||||
assert response == ""
|
||||
assert isinstance(response, list)
|
||||
assert len(response) == 1
|
||||
assert response[0].function.name == "get_weather"
|
||||
assert response[0].function.arguments == '{"location":"New York, NY"}'
|
||||
|
||||
assert_event_count(
|
||||
mock_emit=mock_emit,
|
||||
expected_stream_chunk=9,
|
||||
expected_completed_llm_call=1,
|
||||
expected_completed_llm_call=0,
|
||||
expected_final_chunk_result='{"location":"New York, NY"}',
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.5a5"
|
||||
__version__ = "1.14.5"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -355,8 +356,19 @@ def update_pyproject_dependencies(
|
||||
|
||||
workspace_packages = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or [])
|
||||
|
||||
current_extra: str | None = None
|
||||
extra_header = re.compile(r"^\s*([A-Za-z0-9_-]+)\s*=\s*\[")
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
match = extra_header.match(line)
|
||||
if match:
|
||||
current_extra = match.group(1)
|
||||
elif line.strip().startswith("]"):
|
||||
current_extra = None
|
||||
|
||||
for pkg in workspace_packages:
|
||||
if pkg == "crewai-files" and current_extra == "file-processing":
|
||||
continue
|
||||
if f"{pkg}==" in line:
|
||||
stripped = line.lstrip()
|
||||
indent = line[: len(line) - len(stripped)]
|
||||
@@ -732,18 +744,23 @@ def _is_prerelease(version: str) -> bool:
|
||||
return any(indicator in v for indicator in _PRERELEASE_INDICATORS)
|
||||
|
||||
|
||||
def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
|
||||
def get_commits_from_last_tag(
|
||||
tag_name: str, version: str, cwd: Path | None = None
|
||||
) -> tuple[str, str]:
|
||||
"""Get commits from the last tag, excluding current version.
|
||||
|
||||
Args:
|
||||
tag_name: Current tag name (e.g., "v1.0.0").
|
||||
version: Current version (e.g., "1.0.0").
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
|
||||
Returns:
|
||||
Tuple of (commit_range, commits) where commits is newline-separated.
|
||||
"""
|
||||
try:
|
||||
all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n")
|
||||
all_tags = run_command(
|
||||
["git", "tag", "--sort=-version:refname"], cwd=cwd
|
||||
).split("\n")
|
||||
prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"]
|
||||
|
||||
if not _is_prerelease(version):
|
||||
@@ -752,22 +769,30 @@ def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
|
||||
if prev_tags:
|
||||
last_tag = prev_tags[0]
|
||||
commit_range = f"{last_tag}..HEAD"
|
||||
commits = run_command(["git", "log", commit_range, "--pretty=format:%s"])
|
||||
commits = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
|
||||
)
|
||||
else:
|
||||
commit_range = "HEAD"
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"])
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
|
||||
except subprocess.CalledProcessError:
|
||||
commit_range = "HEAD"
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"])
|
||||
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
|
||||
|
||||
return commit_range, commits
|
||||
|
||||
|
||||
def get_github_contributors(commit_range: str) -> list[str]:
|
||||
def get_github_contributors(
|
||||
commit_range: str,
|
||||
repo: str = "crewAIInc/crewAI",
|
||||
cwd: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Get GitHub usernames from commit range using GitHub API.
|
||||
|
||||
Args:
|
||||
commit_range: Git commit range (e.g., "abc123..HEAD").
|
||||
repo: GitHub repo in ``owner/name`` form to resolve commits against.
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
|
||||
Returns:
|
||||
List of GitHub usernames sorted alphabetically.
|
||||
@@ -779,10 +804,10 @@ def get_github_contributors(commit_range: str) -> list[str]:
|
||||
gh_token = None
|
||||
|
||||
g = Github(login_or_token=gh_token) if gh_token else Github()
|
||||
github_repo = g.get_repo("crewAIInc/crewAI")
|
||||
github_repo = g.get_repo(repo)
|
||||
|
||||
commit_shas = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%H"]
|
||||
["git", "log", commit_range, "--pretty=format:%H"], cwd=cwd
|
||||
).split("\n")
|
||||
|
||||
contributors = set()
|
||||
@@ -922,9 +947,26 @@ def _generate_release_notes(
|
||||
version: str,
|
||||
tag_name: str,
|
||||
no_edit: bool,
|
||||
cwd: Path | None = None,
|
||||
gh_repo: str = "crewAIInc/crewAI",
|
||||
openai_client: OpenAI | None = None,
|
||||
bump_already_done: bool = True,
|
||||
) -> tuple[str, OpenAI, bool]:
|
||||
"""Generate, display, and optionally edit release notes.
|
||||
|
||||
Args:
|
||||
version: Version being released.
|
||||
tag_name: Tag name for the release.
|
||||
no_edit: Skip the interactive edit prompt.
|
||||
cwd: Directory to run git commands in (defaults to current).
|
||||
gh_repo: GitHub repo (``owner/name``) for resolving contributors.
|
||||
openai_client: Reuse an existing OpenAI client if provided.
|
||||
bump_already_done: True when the ``feat: bump versions to <version>``
|
||||
commit for the current release is already in history (the real
|
||||
release path). False in previews where no bump exists yet — the
|
||||
most recent bump commit is the *previous* version and must be
|
||||
used as the range start.
|
||||
|
||||
Returns:
|
||||
Tuple of (release_notes, openai_client, is_prerelease).
|
||||
"""
|
||||
@@ -939,7 +981,8 @@ def _generate_release_notes(
|
||||
"log",
|
||||
"--grep=^feat: bump versions to",
|
||||
"--format=%H %s",
|
||||
]
|
||||
],
|
||||
cwd=cwd,
|
||||
)
|
||||
bump_entries = [
|
||||
line for line in prev_bump_output.strip().split("\n") if line.strip()
|
||||
@@ -947,7 +990,8 @@ def _generate_release_notes(
|
||||
|
||||
is_stable = not _is_prerelease(version)
|
||||
prev_commit = None
|
||||
for entry in bump_entries[1:]:
|
||||
scan_entries = bump_entries[1:] if bump_already_done else bump_entries
|
||||
for entry in scan_entries:
|
||||
bump_ver = entry.split("feat: bump versions to", 1)[-1].strip()
|
||||
if is_stable and _is_prerelease(bump_ver):
|
||||
continue
|
||||
@@ -957,7 +1001,7 @@ def _generate_release_notes(
|
||||
if prev_commit:
|
||||
commit_range = f"{prev_commit}..HEAD"
|
||||
commits = run_command(
|
||||
["git", "log", commit_range, "--pretty=format:%s"]
|
||||
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
|
||||
)
|
||||
|
||||
commit_lines = [
|
||||
@@ -967,14 +1011,21 @@ def _generate_release_notes(
|
||||
]
|
||||
commits = "\n".join(commit_lines)
|
||||
else:
|
||||
commit_range, commits = get_commits_from_last_tag(tag_name, version)
|
||||
commit_range, commits = get_commits_from_last_tag(
|
||||
tag_name, version, cwd=cwd
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
commit_range, commits = get_commits_from_last_tag(tag_name, version)
|
||||
commit_range, commits = get_commits_from_last_tag(
|
||||
tag_name, version, cwd=cwd
|
||||
)
|
||||
|
||||
github_contributors = get_github_contributors(commit_range)
|
||||
github_contributors = get_github_contributors(
|
||||
commit_range, repo=gh_repo, cwd=cwd
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
if openai_client is None:
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
if commits.strip():
|
||||
contributors_section = ""
|
||||
@@ -1532,7 +1583,13 @@ def _wait_for_pr_merged(branch: str, cwd: Path) -> None:
|
||||
time.sleep(_PR_MERGE_POLL_INTERVAL)
|
||||
|
||||
|
||||
def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None:
|
||||
def _release_enterprise(
|
||||
version: str,
|
||||
is_prerelease: bool,
|
||||
dry_run: bool,
|
||||
no_edit: bool = False,
|
||||
openai_client: OpenAI | None = None,
|
||||
) -> None:
|
||||
"""Clone the enterprise repo, bump versions, and create a release PR.
|
||||
|
||||
Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and
|
||||
@@ -1542,6 +1599,8 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
|
||||
version: New version string.
|
||||
is_prerelease: Whether this is a pre-release version.
|
||||
dry_run: Show what would be done without making changes.
|
||||
no_edit: Skip the interactive release-notes edit prompt.
|
||||
openai_client: Reuse OpenAI client from earlier phases if available.
|
||||
"""
|
||||
if (
|
||||
not _ENTERPRISE_REPO
|
||||
@@ -1559,7 +1618,6 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[dim][DRY RUN][/dim] Would clone {enterprise_repo}")
|
||||
for d in _ENTERPRISE_VERSION_DIRS:
|
||||
console.print(f"[dim][DRY RUN][/dim] Would update versions in {d}")
|
||||
console.print(
|
||||
@@ -1570,6 +1628,26 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
|
||||
"[dim][DRY RUN][/dim] Would create bump PR, wait for merge, "
|
||||
"then tag and release"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo_dir = Path(tmp) / enterprise_repo.split("/")[-1]
|
||||
console.print(f"\nCloning {enterprise_repo} (read-only preview)...")
|
||||
run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)])
|
||||
console.print(f"[green]✓[/green] Cloned {enterprise_repo}")
|
||||
|
||||
_generate_release_notes(
|
||||
version,
|
||||
version,
|
||||
no_edit,
|
||||
cwd=repo_dir,
|
||||
gh_repo=enterprise_repo,
|
||||
openai_client=openai_client,
|
||||
bump_already_done=False,
|
||||
)
|
||||
console.print(
|
||||
"[dim][DRY RUN][/dim] Would tag and create GitHub release "
|
||||
"with the notes above"
|
||||
)
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1682,8 +1760,18 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
|
||||
run_command(["git", "pull"], cwd=repo_dir)
|
||||
|
||||
tag_name = version
|
||||
|
||||
release_notes, _, _ = _generate_release_notes(
|
||||
version,
|
||||
tag_name,
|
||||
no_edit,
|
||||
cwd=repo_dir,
|
||||
gh_repo=enterprise_repo,
|
||||
openai_client=openai_client,
|
||||
)
|
||||
|
||||
run_command(
|
||||
["git", "tag", "-a", tag_name, "-m", f"Release {version}"],
|
||||
["git", "tag", "-a", tag_name, "-m", release_notes],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
run_command(["git", "push", "origin", tag_name], cwd=repo_dir)
|
||||
@@ -1699,7 +1787,7 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
|
||||
"--title",
|
||||
tag_name,
|
||||
"--notes",
|
||||
f"Release {version}",
|
||||
release_notes,
|
||||
]
|
||||
if is_prerelease:
|
||||
gh_cmd.append("--prerelease")
|
||||
@@ -1998,7 +2086,7 @@ def tag(dry_run: bool, no_edit: bool) -> None:
|
||||
console.print("[green]✓[/green] main branch up to date")
|
||||
|
||||
release_notes, openai_client, is_prerelease = _generate_release_notes(
|
||||
version, tag_name, no_edit
|
||||
version, tag_name, no_edit, bump_already_done=True
|
||||
)
|
||||
|
||||
docs_branch = _update_docs_and_create_pr(
|
||||
@@ -2109,7 +2197,7 @@ def release(
|
||||
|
||||
if skip_to_enterprise:
|
||||
try:
|
||||
_release_enterprise(version, is_prerelease, dry_run)
|
||||
_release_enterprise(version, is_prerelease, dry_run, no_edit=no_edit)
|
||||
except BaseException as e:
|
||||
_print_release_error(e)
|
||||
_resume_hint(
|
||||
@@ -2205,7 +2293,7 @@ def release(
|
||||
console.print("[green]✓[/green] main branch up to date")
|
||||
|
||||
release_notes, openai_client, is_prerelease = _generate_release_notes(
|
||||
version, tag_name, no_edit
|
||||
version, tag_name, no_edit, bump_already_done=not dry_run
|
||||
)
|
||||
|
||||
docs_branch = _update_docs_and_create_pr(
|
||||
@@ -2259,7 +2347,13 @@ def release(
|
||||
|
||||
if not skip_enterprise:
|
||||
try:
|
||||
_release_enterprise(version, is_prerelease, dry_run)
|
||||
_release_enterprise(
|
||||
version,
|
||||
is_prerelease,
|
||||
dry_run,
|
||||
no_edit=no_edit,
|
||||
openai_client=openai_client,
|
||||
)
|
||||
except BaseException as e:
|
||||
_print_release_error(e)
|
||||
_resume_hint(
|
||||
|
||||
@@ -282,6 +282,25 @@ class TestUpdatePyprojectDependencies:
|
||||
assert '"crewai-files==2.0.0"' in result
|
||||
assert '"requests>=2.0"' in result
|
||||
|
||||
def test_skips_crewai_files_in_file_processing_extra(self, tmp_path: Path) -> None:
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
dedent("""\
|
||||
[project.optional-dependencies]
|
||||
file-processing = [
|
||||
"crewai-files==1.0.0",
|
||||
]
|
||||
other = [
|
||||
"crewai-files==1.0.0",
|
||||
]
|
||||
""")
|
||||
)
|
||||
|
||||
update_pyproject_dependencies(pyproject, "2.0.0")
|
||||
result = pyproject.read_text()
|
||||
assert '"crewai-files==1.0.0"' in result
|
||||
assert '"crewai-files==2.0.0"' in result
|
||||
|
||||
def test_leaves_bare_crewai_pin_alone(self, tmp_path: Path) -> None:
|
||||
"""`crewai==` must not collide with `crewai-core==` etc."""
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
|
||||
@@ -185,8 +185,10 @@ exclude-newer = "3 days"
|
||||
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
|
||||
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
|
||||
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
|
||||
# langsmith <0.7.31 has GHSA-rr7j-v2q5-chgv (streaming token redaction bypass); force 0.7.31+.
|
||||
# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+.
|
||||
# authlib <1.6.11 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage).
|
||||
# pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected.
|
||||
# paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core.
|
||||
# litellm 1.83.8+ hard-pins openai==2.24.0, missing openai.types.responses used by crewai;
|
||||
# override to >=2.30.0 (the version litellm 1.83.7 used) until upstream relaxes the pin.
|
||||
override-dependencies = [
|
||||
@@ -203,8 +205,10 @@ override-dependencies = [
|
||||
"uv>=0.11.6,<1",
|
||||
"python-multipart>=0.0.27,<1",
|
||||
"gitpython>=3.1.50,<4",
|
||||
"langsmith>=0.7.31,<0.8",
|
||||
"langsmith>=0.8.0,<1",
|
||||
"authlib>=1.6.11",
|
||||
"pip>=26.1.1",
|
||||
"paramiko>=5.0.0",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
30
uv.lock
generated
30
uv.lock
generated
@@ -13,7 +13,7 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-05-08T16:33:02.834109Z"
|
||||
exclude-newer = "2026-05-17T14:20:01.778505Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
@@ -31,10 +31,12 @@ overrides = [
|
||||
{ name = "gitpython", specifier = ">=3.1.50,<4" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
|
||||
{ name = "langsmith", specifier = ">=0.7.31,<0.8" },
|
||||
{ name = "langsmith", specifier = ">=0.8.0,<1" },
|
||||
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
|
||||
{ name = "openai", specifier = ">=2.30.0,<3" },
|
||||
{ name = "paramiko", specifier = ">=5.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pip", specifier = ">=26.1.1" },
|
||||
{ name = "pypdf", specifier = ">=6.10.2,<7" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
@@ -3268,11 +3270,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3888,7 +3890,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
|
||||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.7.32"
|
||||
version = "0.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -3901,9 +3903,9 @@ dependencies = [
|
||||
{ name = "xxhash" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/1e8ea5e8bab2a65fa95bd36229ef38e8723ec46e430e20ca2d953487a7f1/langsmith-0.8.3.tar.gz", hash = "sha256:767ff7a8d136ed42926bf99059ac631dc6883542d6e3104b32e71c7625e1fa05", size = 4460330, upload-time = "2026-05-07T19:56:56.18Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/a9/51e644c1f1dbc3dd7d22dfd6412eab206d538c81e024e4f287373544bdcb/langsmith-0.8.3-py3-none-any.whl", hash = "sha256:b2e40e308222fa0beb2dccee3b4b30bfee9062d7a4f20a3e3e93df3c51a08ab4", size = 399048, upload-time = "2026-05-07T19:56:53.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5788,7 +5790,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "paramiko"
|
||||
version = "4.0.0"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
@@ -5796,9 +5798,9 @@ dependencies = [
|
||||
{ name = "invoke" },
|
||||
{ name = "pynacl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6060,11 +6062,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.1"
|
||||
version = "26.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user