Compare commits

..

33 Commits

Author SHA1 Message Date
Greyson LaLonde
01210d582f Merge branch 'main' into gl/feat/a2ui-extension 2026-03-26 20:09:28 +08:00
Greyson Lalonde
225b51cbf1 fix: add missing json schema fields; export 2026-03-26 16:33:20 +08:00
João Moura
6193e082e1 docs: update changelog and version for v1.12.2 (#5103)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
2026-03-26 03:54:26 -03:00
João Moura
33f33c6fcc feat: bump versions to 1.12.2 (#5101) 2026-03-26 03:33:10 -03:00
alex-clawd
74976b157d fix: preserve method return value as flow output for @human_feedback with emit (#5099)
* fix: preserve method return value as flow output for @human_feedback with emit

When a @human_feedback decorated method with emit= is the final method in a
flow (no downstream listeners triggered), the flow's final output was
incorrectly set to the collapsed outcome string (e.g., 'approved') instead
of the method's actual return value (e.g., a state dict).

Root cause: _process_feedback() returns the collapsed_outcome string when
emit is set, and this string was being stored as the method's result in
_method_outputs.

The fix:
1. In human_feedback.py: After _process_feedback, stash the real method_output
   on the flow instance as _human_feedback_method_output when emit is set.

2. In flow.py: After appending a method result to _method_outputs, check if
   _human_feedback_method_output is set. If so, replace the last entry with
   the stashed real output and clear the stash.

This ensures:
- Routing still works correctly (collapsed outcome used for @listen matching)
- The flow's final result is the actual method return value
- If downstream listeners execute, their results become the final output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: ruff format flow.py

* fix: use per-method dict stash for concurrency safety and None returns

Addresses review comments:
- Replace single flow-level slot with dict keyed by method name,
  safe under concurrent @human_feedback+emit execution
- Dict key presence (not value) indicates stashed output,
  correctly preserving None return values
- Added test for None return value preservation

---------

Co-authored-by: Joao Moura <joao@crewai.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 03:28:17 -03:00
Greyson Lalonde
65a6d91204 chore: update docs to reflect correct kwarg 2026-03-26 13:09:04 +08:00
Greyson Lalonde
75ea710ad2 Merge branch 'main' into gl/feat/a2ui-extension 2026-03-26 13:00:09 +08:00
Greyson LaLonde
bd03f6cf64 feat: add enterprise release phase to devtools release
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
2026-03-26 12:22:37 +08:00
Rip&Tear
a91cd1a7d7 Revise security policy and reporting instructions (#5096)
* Revise security policy and reporting instructions

Updated the security reporting process and contact details.

* Update .github/security.md
---------
2026-03-26 10:50:21 +08:00
João Moura
66dee3195f docs: update changelog and version for v1.12.1 (#5095) 2026-03-25 22:52:11 -03:00
João Moura
034f576dc0 feat: bump versions to 1.12.1 (#5094)
* chore: bump version to 1.12.1 across all modules

* feat: bump versions to 1.12.1
2026-03-25 22:45:33 -03:00
Lucas Gomide
918654318b feat: add request_id to HumanFeedbackRequestedEvent (#5092)
* feat: add request_id to HumanFeedbackRequestedEvent

Allow platforms to attach a correlation identifier to human feedback requests so downstream consumers can deterministically match spans to their corresponding feedback records

* feat: add request_id to HumanFeedbackReceivedEvent for correlation

Without request_id on the received event, consumers cannot correlate
a feedback response back to its originating request. Both sides of the
request/response pair need the correlation identifier.

---------

Co-authored-by: Alex <alex@crewai.com>
2026-03-25 22:43:24 -03:00
João Moura
371e6cfd11 docs: update changelog and version for v1.12.0 (#5091) 2026-03-25 22:07:28 -03:00
João Moura
6fd70ce6e5 chore: bump version to 1.14.0 across all modules (#5090)
* chore: bump version to 1.14.0 across all modules

* chore: downgrade version to 1.12.0 across all modules
2026-03-25 22:03:37 -03:00
alex-clawd
c183b77991 fix: address Copilot review on OpenAI-compatible providers (#5042) (#5089)
Some checks failed
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
- Delegate supports_function_calling() to parent (handles o1 models via OpenRouter)
- Guard empty env vars in base_url resolution
- Fix misleading comment about model validation rules
- Remove unused MagicMock import
- Use 'is not None' for env var restoration in tests

Co-authored-by: Joao Moura <joao@crewai.com>
2026-03-25 18:22:13 -03:00
Greyson LaLonde
b5a0d6e709 docs: update changelog and version for v1.12.0a3 2026-03-26 04:17:37 +08:00
Greyson LaLonde
454156cff9 feat: bump versions to 1.12.0a3 2026-03-26 04:12:49 +08:00
Tiago Freire
d86707da3d Fix: bad credentials for traces batch push (404) (#4947)
## Summary

### Core fixes

<details>
<summary><b>Fix silent 404 cascade on trace event send</b></summary>

When `_initialize_backend_batch` failed, `trace_batch_id` was left populated with a client-generated UUID never registered server-side. All subsequent event sends hit a non-existent batch endpoint and returned 404. Now all three failure paths (None response, non-2xx status, exception) clear `trace_batch_id`.
</details>

<details>
<summary><b>Fix first-time deferred batch init silently skipped</b></summary>

First-time users have `is_tracing_enabled_in_context() = False` by design. This caused `_initialize_backend_batch` to return early without creating the batch, and `finalize_batch` to skip finalization (same guard). The first-time handler now passes `skip_context_check=True` to bypass both guards, calls `_finalize_backend_batch` directly, gates `backend_initialized` on actual success, checks `_send_events_to_backend` return status (marking batch as failed on 500), captures event count/duration/batch ID before they're consumed by send/finalize, and cleans up all singleton state via `_reset_batch_state()` on every exit path.
</details>

<details>
<summary><b>Sync <code>is_current_batch_ephemeral</code> on batch creation success</b></summary>

When the batch is successfully created on the server, `is_current_batch_ephemeral` is now synced with the actual `use_ephemeral` value used. This prevents endpoint mismatches where the batch was created on one endpoint but events and finalization were sent to a different one, resulting in 404.
</details>

<details>
<summary><b>Route <code>mark_trace_batch_as_failed</code> to correct endpoint for ephemeral batches</b></summary>

`mark_trace_batch_as_failed` always routed to the non-ephemeral endpoint (`/tracing/batches/{id}`), causing 404s when called on ephemeral batches — the same class of endpoint mismatch this PR aims to fix. Added `mark_ephemeral_trace_batch_as_failed` to `PlusAPI` and a `_mark_batch_as_failed` helper on `TraceBatchManager` that routes based on `is_current_batch_ephemeral`.
</details>

<details>
<summary><b>Gate <code>backend_initialized</code> on actual init success (non-first-time path)</b></summary>

On the non-first-time path, `backend_initialized` was set to `True` unconditionally after `_initialize_backend_batch` returned. With the new failure-path cleanup that clears `trace_batch_id`, this created an inconsistent state: `backend_initialized=True` + `trace_batch_id=None`. Now set via `self.trace_batch_id is not None`.
</details>

### Resilience improvements

<details>
<summary><b>Retry transient failures on batch creation</b></summary>

`_initialize_backend_batch` now retries up to 2 times with 200ms backoff on transient failures (None response, 5xx, network errors). Non-transient 4xx errors are not retried. The short backoff minimizes lock hold time on the non-first-time path where `_batch_ready_cv` is held.
</details>

<details>
<summary><b>Fall back to ephemeral on server auth rejection</b></summary>

When the non-ephemeral endpoint returns 401/403 (expired token, revoked credentials, key rotation), the client automatically switches to ephemeral tracing instead of losing traces. The fallback forwards `skip_context_check` and is guarded against infinite recursion — if ephemeral also fails, `trace_batch_id` is cleared normally.
</details>

<details>
<summary><b>Fix action-event race initializing batch as non-ephemeral</b></summary>

`_handle_action_event` called `batch_manager.initialize_batch()` directly, defaulting `use_ephemeral=False`. When a `DefaultEnvEvent` or `LLMCallStartedEvent` fired before `CrewKickoffStartedEvent` in the thread pool, the batch was locked in as non-ephemeral. Now routes through `_initialize_batch()` which computes `use_ephemeral` from `_check_authenticated()`.
</details>

<details>
<summary><b>Guard <code>_mark_batch_as_failed</code> against cascading network errors</b></summary>

When `_finalize_backend_batch` failed with a network error (e.g. `[Errno 54] Connection reset by peer`), the exception handler called `_mark_batch_as_failed` — which also makes an HTTP request on the same dead connection. That second failure was unhandled. Now wrapped in a try/except so it logs at debug level instead of propagating.
</details>

<details>
<summary><b>Design decision: first-time users always use ephemeral</b></summary>

First-time trace collection **always creates ephemeral batches**, regardless of authentication status. This is intentional:

1. **The first-time handler UX is built around ephemeral traces** — it displays an access code, a 24-hour expiry link, and opens the browser to the ephemeral trace viewer. Non-ephemeral batches don't produce these artifacts, so the handler would fall through to the "Local Traces Collected" fallback even when traces were successfully sent.

2. **The server handles account linking automatically** — `LinkEphemeralTracesJob` runs on user signup and migrates ephemeral traces to permanent records. Logged-in users can access their traces via their dashboard regardless.

3. **Checking auth during batch setup broke event collection** — moving `_check_authenticated()` into `_initialize_batch` caused the batch initialization to fail silently during the flow/crew start event handler, preventing all event collection. Keeping the first-time path fast and side-effect-free preserves event collection.

The auth check is deferred to the non-first-time path (second run onwards), where `is_tracing_enabled_in_context()` is `True` and the normal tracing pipeline handles everything — including the 401/403 ephemeral fallback.
</details>


### Manual tests


<details>
<summary><b>Matrix</b></summary>

| Scenario | First run | Second run |
|----------|-----------|------------|
| Logged out, fresh `.crewai_user.json` | Ephemeral trace created, URL returned | Ephemeral trace created, URL returned |
| Logged in, fresh `.crewai_user.json` | Ephemeral trace created, URL returned | Trace batch finalized, URL returned |
| Flow execution | Tested with `poem_flow` | Tested with `poem_flow` |
| Crew execution | Tested with `hitl_crew` | Tested with `hitl_crew` |
</details>
2026-03-25 16:00:05 -04:00
Greyson LaLonde
1956471086 fix: resolve multiple bugs in HITL flow system 2026-03-26 03:33:03 +08:00
Greyson LaLonde
4022516b1f Merge branch 'main' into gl/feat/a2ui-extension 2026-03-25 13:47:41 +08:00
Greyson LaLonde
27bd16fd0c fix: use A2UI_EXTENSION_URI for catalog label 2026-03-14 21:28:45 -04:00
Greyson LaLonde
6cc0abbdc6 fix: use typing_extensions.TypedDict for Pydantic compatibility on Python < 3.12 2026-03-14 19:19:21 -04:00
Greyson Lalonde
bc20b96538 refactor: add Field descriptions, explicit defaults, and ConfigDict to A2UI models 2026-03-14 17:25:58 -04:00
Greyson Lalonde
d2e74fc0be refactor: improve typing and use instance state in A2UI client extension 2026-03-14 16:38:05 -04:00
Greyson Lalonde
afb6cbbb6e refactor: add Field descriptions, explicit defaults, and ConfigDict to A2UI catalog models 2026-03-14 15:25:20 -04:00
Greyson Lalonde
e63c6310ed style: consolidate catalog imports in A2UI conformance tests 2026-03-14 13:24:07 -04:00
Greyson LaLonde
aa01d3130f Merge branch 'main' into gl/feat/a2ui-extension 2026-03-14 03:07:44 -04:00
Greyson Lalonde
e6fa652364 fix: replace getattr with direct metadata access on DataPart 2026-03-14 02:55:35 -04:00
Greyson Lalonde
d653a40394 docs: register A2UI guide in docs navigation 2026-03-14 02:48:09 -04:00
Greyson Lalonde
b8f47aaa6b test: add A2UI schema conformance tests 2026-03-14 02:47:09 -04:00
Greyson Lalonde
4ee32fc7d1 feat: add A2UI content type to A2A utils 2026-03-14 02:43:49 -04:00
Greyson Lalonde
c8d776f62a docs: add A2UI extension guide 2026-03-14 02:42:36 -04:00
Greyson Lalonde
b2a2902f00 feat: add A2UI v0.8 extension for declarative UI generation 2026-03-14 02:41:48 -04:00
53 changed files with 11268 additions and 304 deletions

50
.github/security.md vendored
View File

@@ -1,50 +1,12 @@
## CrewAI Security Policy
We are committed to protecting the confidentiality, integrity, and availability of the CrewAI ecosystem. This policy explains how to report potential vulnerabilities and what you can expect from us when you do.
### Scope
We welcome reports for vulnerabilities that could impact:
- CrewAI-maintained source code and repositories
- CrewAI-operated infrastructure and services
- Official CrewAI releases, packages, and distributions
Issues affecting clearly unaffiliated third-party services or user-generated content are out of scope, unless you can demonstrate a direct impact on CrewAI systems or customers.
We are committed to protecting the confidentiality, integrity, and availability of the
CrewAI ecosystem.
### How to Report
- **Please do not** disclose vulnerabilities via public GitHub issues, pull requests, or social media.
- Email detailed reports to **security@crewai.com** with the subject line `Security Report`.
- If you need to share large files or sensitive artifacts, mention it in your email and we will coordinate a secure transfer method.
Please submit reports to **crewai-vdp-ess@submit.bugcrowd.com**
### What to Include
Providing comprehensive information enables us to validate the issue quickly:
- **Vulnerability overview** — a concise description and classification (e.g., RCE, privilege escalation)
- **Affected components** — repository, branch, tag, or deployed service along with relevant file paths or endpoints
- **Reproduction steps** — detailed, step-by-step instructions; include logs, screenshots, or screen recordings when helpful
- **Proof-of-concept** — exploit details or code that demonstrates the impact (if available)
- **Impact analysis** — severity assessment, potential exploitation scenarios, and any prerequisites or special configurations
### Our Commitment
- **Acknowledgement:** We aim to acknowledge your report within two business days.
- **Communication:** We will keep you informed about triage results, remediation progress, and planned release timelines.
- **Resolution:** Confirmed vulnerabilities will be prioritized based on severity and fixed as quickly as possible.
- **Recognition:** We currently do not run a bug bounty program; any rewards or recognition are issued at CrewAI's discretion.
### Coordinated Disclosure
We ask that you allow us a reasonable window to investigate and remediate confirmed issues before any public disclosure. We will coordinate publication timelines with you whenever possible.
### Safe Harbor
We will not pursue or support legal action against individuals who, in good faith:
- Follow this policy and refrain from violating any applicable laws
- Avoid privacy violations, data destruction, or service disruption
- Limit testing to systems in scope and respect rate limits and terms of service
If you are unsure whether your testing is covered, please contact us at **security@crewai.com** before proceeding.
- **Please do not** disclose vulnerabilities via public GitHub issues, pull requests,
or social media
- Reports submitted via channels other than this Bugcrowd submission email will not be reviewed and will be dismissed

View File

@@ -4,6 +4,134 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="25 مارس 2026">
## v1.12.2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## ما الذي تغير
### الميزات
- إضافة مرحلة إصدار المؤسسات إلى إصدار أدوات المطورين
### إصلاحات الأخطاء
- الحفاظ على قيمة إرجاع الطريقة كإخراج تدفق لـ @human_feedback مع emit
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.1
- مراجعة سياسة الأمان وتعليمات الإبلاغ
## المساهمون
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 مارس 2026">
## v1.12.1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.1)
## ما الذي تغير
### الميزات
- إضافة request_id إلى HumanFeedbackRequestedEvent
- إضافة Qdrant Edge كخلفية تخزين لنظام الذاكرة
- إضافة أمر docs-check لتحليل التغييرات وتوليد الوثائق مع الترجمات
- إضافة دعم اللغة العربية إلى سجل التغييرات وأدوات الإصدار
- إضافة ترجمة باللغة العربية الفصحى لجميع الوثائق
- إضافة أمر تسجيل الخروج في واجهة سطر الأوامر
- إضافة مهارات الوكيل
- تنفيذ root_scope تلقائيًا لعزل الذاكرة الهيكلية
- تنفيذ مزودين متوافقين مع OpenAI (OpenRouter، DeepSeek، Ollama، vLLM، Cerebras، Dashscope)
### إصلاحات الأخطاء
- إصلاح بيانات اعتماد غير صحيحة لدفع دفعات التتبع (404)
- حل العديد من الأخطاء في نظام تدفق HITL
- إصلاح حفظ ذاكرة الوكيل
- حل جميع أخطاء mypy الصارمة عبر حزمة crewai
- إصلاح استخدام __router_paths__ لطرق المستمع + الموجه في FlowMeta
- إصلاح خطأ القيمة عند عدم دعم الملفات
- تصحيح صياغة الحجر الصحي لـ litellm في الوثائق
- إصلاح جميع أخطاء mypy في crewai-files وإضافة جميع الحزم إلى فحوصات النوع في CI
- تثبيت الحد الأعلى لـ litellm على آخر إصدار تم اختباره (1.82.6)
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.0
- إضافة CONTRIBUTING.md
- إضافة دليل لاستخدام CrewAI بدون LiteLLM
## المساهمون
@akaKuruma، @alex-clawd، @greysonlalonde، @iris-clawd، @joaomdmoura، @lorenzejay، @lucasgomide، @nicoferdi96
</Update>
<Update label="25 مارس 2026">
## v1.12.0
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0)
## ما الذي تغير
### الميزات
- إضافة واجهة تخزين Qdrant Edge لنظام الذاكرة
- إضافة أمر docs-check لتحليل التغييرات وتوليد الوثائق مع الترجمات
- إضافة دعم اللغة العربية لسجل التغييرات وأدوات الإصدار
- إضافة ترجمة اللغة العربية الفصحى لجميع الوثائق
- إضافة أمر تسجيل الخروج في واجهة سطر الأوامر
- تنفيذ مهارات الوكيل
- تنفيذ نطاق الجذر التلقائي لعزل الذاكرة الهرمية
- تنفيذ موفري خدمات متوافقين مع OpenAI (OpenRouter، DeepSeek، Ollama، vLLM، Cerebras، Dashscope)
### إصلاح الأخطاء
- إصلاح بيانات الاعتماد السيئة لدفع دفعات التتبع (404)
- حل العديد من الأخطاء في نظام تدفق HITL
- حل أخطاء mypy في crewai-files وإضافة جميع الحزم إلى فحوصات نوع CI
- حل جميع أخطاء mypy الصارمة عبر حزمة crewai-tools
- حل جميع أخطاء mypy عبر حزمة crewai
- إصلاح حفظ الذاكرة في الوكيل
- إصلاح استخدام __router_paths__ لطرق المستمع + الموجه في FlowMeta
- رفع خطأ القيمة عند عدم دعم الملفات
- تصحيح صياغة الحجر الصحي لـ litellm في الوثائق
- استخدام فحص None بدلاً من isinstance للذاكرة في تعلم التغذية الراجعة البشرية
- تثبيت الحد الأعلى لـ litellm على آخر إصدار تم اختباره (1.82.6)
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.0
- إضافة CONTRIBUTING.md
- إضافة دليل لاستخدام CrewAI بدون LiteLLM
### إعادة الهيكلة
- إعادة هيكلة لتجنب تكرار تنفيذ المهام المتزامنة / غير المتزامنة وبدء التشغيل في الوكيل
- تبسيط الأنابيب الداخلية من litellm (عد الرموز، ردود النداء، اكتشاف الميزات، الأخطاء)
## المساهمون
@akaKuruma، @alex-clawd، @greysonlalonde، @iris-clawd، @joaomdmoura، @lorenzejay، @nicoferdi96
</Update>
<Update label="26 مارس 2026">
## v1.12.0a3
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0a3)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح بيانات الاعتماد الخاطئة لدفع دفعات التتبع (404)
- حل العديد من الأخطاء في نظام تدفق HITL
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.12.0a2
## المساهمون
@akaKuruma, @greysonlalonde
</Update>
<Update label="25 مارس 2026">
## v1.12.0a2

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,134 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Mar 25, 2026">
## v1.12.2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## What's Changed
### Features
- Add enterprise release phase to devtools release
### Bug Fixes
- Preserve method return value as flow output for @human_feedback with emit
### Documentation
- Update changelog and version for v1.12.1
- Revise security policy and reporting instructions
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="Mar 25, 2026">
## v1.12.1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.1)
## What's Changed
### Features
- Add request_id to HumanFeedbackRequestedEvent
- Add Qdrant Edge storage backend for memory system
- Add docs-check command to analyze changes and generate docs with translations
- Add Arabic language support to changelog and release tooling
- Add modern standard Arabic translation of all documentation
- Add logout command in CLI
- Add agent skills
- Implement automatic root_scope for hierarchical memory isolation
- Implement native OpenAI-compatible providers (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### Bug Fixes
- Fix bad credentials for traces batch push (404)
- Resolve multiple bugs in HITL flow system
- Fix agent memory saving
- Resolve all strict mypy errors across crewai package
- Fix use of __router_paths__ for listener+router methods in FlowMeta
- Fix value error on no file support
- Correct litellm quarantine wording in docs
- Fix all mypy errors in crewai-files and add all packages to CI type checks
- Pin litellm upper bound to last tested version (1.82.6)
### Documentation
- Update changelog and version for v1.12.0
- Add CONTRIBUTING.md
- Add guide for using CrewAI without LiteLLM
## Contributors
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @nicoferdi96
</Update>
<Update label="Mar 25, 2026">
## v1.12.0
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0)
## What's Changed
### Features
- Add Qdrant Edge storage backend for memory system
- Add docs-check command to analyze changes and generate docs with translations
- Add Arabic language support to changelog and release tooling
- Add modern standard Arabic translation of all documentation
- Add logout command in CLI
- Implement agent skills
- Implement automatic root_scope for hierarchical memory isolation
- Implement native OpenAI-compatible providers (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### Bug Fixes
- Fix bad credentials for traces batch push (404)
- Resolve multiple bugs in HITL flow system
- Resolve mypy errors in crewai-files and add all packages to CI type checks
- Resolve all strict mypy errors across crewai-tools package
- Resolve all mypy errors across crewai package
- Fix memory saving in agent
- Fix usage of __router_paths__ for listener+router methods in FlowMeta
- Raise value error on no file support
- Correct litellm quarantine wording in docs
- Use None check instead of isinstance for memory in human feedback learn
- Pin litellm upper bound to last tested version (1.82.6)
### Documentation
- Update changelog and version for v1.12.0
- Add CONTRIBUTING.md
- Add guide for using CrewAI without LiteLLM
### Refactoring
- Refactor to deduplicate sync/async task execution and kickoff in agent
- Simplify internal plumbing from litellm (token counting, callbacks, feature detection, errors)
## Contributors
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @nicoferdi96
</Update>
<Update label="Mar 26, 2026">
## v1.12.0a3
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0a3)
## What's Changed
### Bug Fixes
- Fix bad credentials for traces batch push (404)
- Resolve multiple bugs in HITL flow system
### Documentation
- Update changelog and version for v1.12.0a2
## Contributors
@akaKuruma, @greysonlalonde
</Update>
<Update label="Mar 25, 2026">
## v1.12.0a2

View File

@@ -1,132 +0,0 @@
---
title: "Training Crews"
description: "Train your deployed crews directly from the CrewAI AMP platform to improve agent performance over time"
icon: "dumbbell"
mode: "wide"
---
Training lets you improve crew performance by running iterative training sessions directly from the **Training** tab in CrewAI AMP. The platform uses **auto-train mode** — it handles the iterative process automatically, unlike CLI training which requires interactive human feedback per iteration.
After training completes, CrewAI evaluates agent outputs and consolidates feedback into actionable suggestions for each agent. These suggestions are then applied to future crew runs to improve output quality.
<Tip>
For details on how CrewAI training works under the hood, see the [Training Concepts](/en/concepts/training) page.
</Tip>
## Prerequisites
<CardGroup cols={2}>
<Card title="Active deployment" icon="rocket">
You need a CrewAI AMP account with an active deployment in **Ready** status (Crew type).
</Card>
<Card title="Run permission" icon="key">
Your account must have run permission for the deployment you want to train.
</Card>
</CardGroup>
## How to train a crew
<Steps>
<Step title="Open the Training tab">
Navigate to **Deployments**, click your deployment, then select the **Training** tab.
</Step>
<Step title="Enter a training name">
Provide a **Training Name** — this becomes the `.pkl` filename used to store training results. For example, "Expert Mode Training" produces `expert_mode_training.pkl`.
</Step>
<Step title="Fill in the crew inputs">
Enter the crew's input fields. These are the same inputs you'd provide for a normal kickoff — they're dynamically loaded based on your crew's configuration.
</Step>
<Step title="Start training">
Click **Train Crew**. The button changes to "Training..." with a spinner while the process runs.
Behind the scenes:
- A training record is created for your deployment
- The platform calls the deployment's auto-train endpoint
- The crew runs its iterations automatically — no manual feedback required
</Step>
<Step title="Monitor progress">
The **Current Training Status** panel displays:
- **Status** — Current state of the training run
- **Nº Iterations** — Number of training iterations configured
- **Filename** — The `.pkl` file being generated
- **Started At** — When training began
- **Training Inputs** — The inputs you provided
</Step>
</Steps>
## Understanding training results
Once training completes, you'll see per-agent result cards with the following information:
- **Agent Role** — The name/role of the agent in your crew
- **Final Quality** — A score from 0 to 10 evaluating the agent's output quality
- **Final Summary** — A summary of the agent's performance during training
- **Suggestions** — Actionable recommendations for improving the agent's behavior
### Editing suggestions
You can refine the suggestions for any agent:
<Steps>
<Step title="Click Edit">
On any agent's result card, click the **Edit** button next to the suggestions.
</Step>
<Step title="Modify suggestions">
Update the suggestions text to better reflect the improvements you want.
</Step>
<Step title="Save changes">
Click **Save**. The edited suggestions sync back to the deployment and are used in all future runs.
</Step>
</Steps>
## Using trained data
To apply training results to your crew:
1. Note the **Training Filename** (the `.pkl` file) from your completed training session.
2. Specify this filename in your deployment's kickoff or run configuration.
3. The crew automatically loads the training file and applies the stored suggestions to each agent.
This means agents benefit from the feedback generated during training on every subsequent run.
## Previous trainings
The bottom of the Training tab displays a **history of all past training sessions** for the deployment. Use this to review previous training runs, compare results, or select a different training file to use.
## Error handling
If a training run fails, the status panel shows an error state along with a message describing what went wrong.
Common causes of training failures:
- **Deployment runtime not updated** — Ensure your deployment is running the latest version
- **Crew execution errors** — Issues within the crew's task logic or agent configuration
- **Network issues** — Connectivity problems between the platform and the deployment
## Limitations
<Info>
Keep these constraints in mind when planning your training workflow:
- **One active training at a time** per deployment — wait for the current run to finish before starting another
- **Auto-train mode only** — the platform does not support interactive per-iteration feedback like the CLI does
- **Training data is deployment-specific** — training results are tied to the specific deployment instance and version
</Info>
## Related resources
<CardGroup cols={3}>
<Card title="Training Concepts" icon="book" href="/en/concepts/training">
Learn how CrewAI training works under the hood.
</Card>
<Card title="Kickoff Crew" icon="play" href="/en/enterprise/guides/kickoff-crew">
Run your deployed crew from the AMP platform.
</Card>
<Card title="Deploy to AMP" icon="cloud-arrow-up" href="/en/enterprise/guides/deploy-to-amp">
Get your crew deployed and ready for training.
</Card>
</CardGroup>

344
docs/en/learn/a2ui.mdx Normal file
View File

@@ -0,0 +1,344 @@
---
title: Agent-to-UI (A2UI) Protocol
description: Enable agents to generate declarative UI surfaces for rich client rendering via the A2UI extension.
icon: window-restore
mode: "wide"
---
## A2UI Overview
A2UI is a declarative UI protocol extension for [A2A](/en/learn/a2a-agent-delegation) that lets agents emit structured JSON messages describing interactive surfaces. Clients receive these messages and render them as rich UI components — forms, cards, lists, modals, and more — without the agent needing to know anything about the client's rendering stack.
A2UI is built on the A2A extension mechanism and identified by the URI `https://a2ui.org/a2a-extension/a2ui/v0.8`.
<Note>
A2UI requires the `a2a-sdk` package. Install with: `uv add 'crewai[a2a]'` or `pip install 'crewai[a2a]'`
</Note>
## How It Works
1. The **server extension** scans agent output for A2UI JSON objects
2. Valid messages are wrapped as `DataPart` entries with the `application/json+a2ui` MIME type
3. The **client extension** augments the agent's system prompt with A2UI instructions and the component catalog
4. The client tracks surface state (active surfaces and data models) across conversation turns
## Server Setup
Add `A2UIServerExtension` to your `A2AServerConfig` to enable A2UI output:
```python Code
from crewai import Agent
from crewai.a2a import A2AServerConfig
from crewai.a2a.extensions.a2ui import A2UIServerExtension
agent = Agent(
role="Dashboard Agent",
goal="Present data through interactive UI surfaces",
backstory="Expert at building clear, actionable dashboards",
llm="gpt-4o",
a2a=A2AServerConfig(
url="https://your-server.com",
server_extensions=[A2UIServerExtension()],
),
)
```
### Server Extension Options
<ParamField path="catalog_ids" type="list[str] | None" default="None">
Component catalog identifiers the server supports. When set, only these catalogs are advertised to clients.
</ParamField>
<ParamField path="accept_inline_catalogs" type="bool" default="False">
Whether to accept inline catalog definitions from clients in addition to named catalogs.
</ParamField>
## Client Setup
Add `A2UIClientExtension` to your `A2AClientConfig` to enable A2UI rendering:
```python Code
from crewai import Agent
from crewai.a2a import A2AClientConfig
from crewai.a2a.extensions.a2ui import A2UIClientExtension
agent = Agent(
role="UI Coordinator",
goal="Coordinate tasks and render agent responses as rich UI",
backstory="Expert at presenting agent output in interactive formats",
llm="gpt-4o",
a2a=A2AClientConfig(
endpoint="https://dashboard-agent.example.com/.well-known/agent-card.json",
client_extensions=[A2UIClientExtension()],
),
)
```
### Client Extension Options
<ParamField path="catalog_id" type="str | None" default="None">
Preferred component catalog identifier. Defaults to `"standard (v0.8)"` when not set.
</ParamField>
<ParamField path="allowed_components" type="list[str] | None" default="None">
Restrict which components the agent may use. When `None`, all 18 standard catalog components are available.
</ParamField>
## Message Types
A2UI defines four server-to-client message types. Each message targets a **surface** identified by `surfaceId`.
<Tabs>
<Tab title="beginRendering">
Initializes a new surface with a root component and optional styles.
```json
{
"beginRendering": {
"surfaceId": "dashboard-1",
"root": "main-column",
"catalogId": "standard (v0.8)",
"styles": {
"primaryColor": "#EB6658"
}
}
}
```
</Tab>
<Tab title="surfaceUpdate">
Sends or updates one or more components on an existing surface.
```json
{
"surfaceUpdate": {
"surfaceId": "dashboard-1",
"components": [
{
"id": "main-column",
"component": {
"Column": {
"children": { "explicitList": ["title", "content"] },
"alignment": "start"
}
}
},
{
"id": "title",
"component": {
"Text": {
"text": { "literalString": "Dashboard" },
"usageHint": "h1"
}
}
}
]
}
}
```
</Tab>
<Tab title="dataModelUpdate">
Updates the data model bound to a surface, enabling dynamic content.
```json
{
"dataModelUpdate": {
"surfaceId": "dashboard-1",
"path": "/data/model",
"contents": [
{
"key": "userName",
"valueString": "Alice"
},
{
"key": "score",
"valueNumber": 42
}
]
}
}
```
</Tab>
<Tab title="deleteSurface">
Removes a surface and all its components.
```json
{
"deleteSurface": {
"surfaceId": "dashboard-1"
}
}
```
</Tab>
</Tabs>
## Component Catalog
A2UI ships with 18 standard components organized into three categories:
### Content
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Text** | Renders text with optional heading/body hints | `text` (StringBinding) |
| **Image** | Displays an image with fit and size options | `url` (StringBinding) |
| **Icon** | Renders a named icon from a set of 47 icons | `name` (IconBinding) |
| **Video** | Embeds a video player | `url` (StringBinding) |
| **AudioPlayer** | Embeds an audio player with optional description | `url` (StringBinding) |
### Layout
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Row** | Horizontal flex container | `children` (ChildrenDef) |
| **Column** | Vertical flex container | `children` (ChildrenDef) |
| **List** | Scrollable list (vertical or horizontal) | `children` (ChildrenDef) |
| **Card** | Elevated container for a single child | `child` (str) |
| **Tabs** | Tabbed container | `tabItems` (list of TabItem) |
| **Divider** | Visual separator (horizontal or vertical) | — |
| **Modal** | Overlay triggered by an entry point | `entryPointChild`, `contentChild` (str) |
### Interactive
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Button** | Clickable button that triggers an action | `child` (str), `action` (Action) |
| **CheckBox** | Boolean toggle with a label | `label` (StringBinding), `value` (BooleanBinding) |
| **TextField** | Text input with type and validation options | `label` (StringBinding) |
| **DateTimeInput** | Date and/or time picker | `value` (StringBinding) |
| **MultipleChoice** | Selection from a list of options | `selections` (ArrayBinding), `options` (list) |
| **Slider** | Numeric range slider | `value` (NumberBinding) |
## Data Binding
Components reference values through **bindings** rather than raw literals. This allows surfaces to update dynamically when the data model changes.
There are two ways to bind a value:
- **Literal values** — hardcoded directly in the component definition
- **Path references** — point to a key in the surface's data model
```json
{
"surfaceUpdate": {
"surfaceId": "profile-1",
"components": [
{
"id": "greeting",
"component": {
"Text": {
"text": { "path": "/data/model/userName" },
"usageHint": "h2"
}
}
},
{
"id": "status",
"component": {
"Text": {
"text": { "literalString": "Online" },
"usageHint": "caption"
}
}
}
]
}
}
```
In this example, `greeting` reads the user's name from the data model (updated via `dataModelUpdate`), while `status` uses a hardcoded literal.
## Handling User Actions
Interactive components like `Button` trigger `userAction` events that flow back to the server. Each action includes a `name`, the originating `surfaceId` and `sourceComponentId`, and an optional `context` with key-value pairs.
```json
{
"userAction": {
"name": "submitForm",
"surfaceId": "form-1",
"sourceComponentId": "submit-btn",
"timestamp": "2026-03-12T10:00:00Z",
"context": {
"selectedOption": "optionA"
}
}
}
```
Action context values can also use path bindings to send current data model values back to the server:
```json
{
"Button": {
"child": "confirm-label",
"action": {
"name": "confirm",
"context": [
{
"key": "currentScore",
"value": { "path": "/data/model/score" }
}
]
}
}
}
```
## Validation
Use `validate_a2ui_message` to validate server-to-client messages and `validate_a2ui_event` for client-to-server events:
```python Code
from crewai.a2a.extensions.a2ui import validate_a2ui_message
from crewai.a2a.extensions.a2ui.validator import (
validate_a2ui_event,
A2UIValidationError,
)
# Validate a server message
try:
msg = validate_a2ui_message({"beginRendering": {"surfaceId": "s1", "root": "r1"}})
except A2UIValidationError as exc:
print(exc.errors)
# Validate a client event
try:
event = validate_a2ui_event({
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
}
})
except A2UIValidationError as exc:
print(exc.errors)
```
## Best Practices
<CardGroup cols={2}>
<Card title="Start Simple" icon="play">
Begin with a `beginRendering` message and a single `surfaceUpdate`. Add data binding and interactivity once the basic flow works.
</Card>
<Card title="Use Data Binding for Dynamic Content" icon="arrows-rotate">
Prefer path bindings over literal values for content that changes. Use `dataModelUpdate` to push new values without resending the full component tree.
</Card>
<Card title="Filter Components" icon="filter">
Use the `allowed_components` option on `A2UIClientExtension` to restrict which components the agent may emit, reducing prompt size and keeping output predictable.
</Card>
<Card title="Validate Messages" icon="check">
Use `validate_a2ui_message` and `validate_a2ui_event` to catch malformed payloads early, especially when building custom integrations.
</Card>
</CardGroup>
## Learn More
- [A2A Agent Delegation](/en/learn/a2a-agent-delegation) — configure agents for remote delegation via the A2A protocol
- [A2A Protocol Documentation](https://a2a-protocol.org) — official protocol specification

View File

@@ -4,6 +4,134 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 3월 25일">
## v1.12.2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## 변경 사항
### 기능
- devtools 릴리스에 기업 릴리스 단계 추가
### 버그 수정
- @human_feedback과 함께 emit을 사용할 때 메서드 반환 값을 흐름 출력으로 유지
### 문서
- v1.12.1에 대한 변경 로그 및 버전 업데이트
- 보안 정책 및 보고 지침 수정
## 기여자
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="2026년 3월 25일">
## v1.12.1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.1)
## 변경 사항
### 기능
- HumanFeedbackRequestedEvent에 request_id 추가
- 메모리 시스템을 위한 Qdrant Edge 저장소 백엔드 추가
- 변경 사항을 분석하고 번역된 문서와 함께 문서를 생성하는 docs-check 명령어 추가
- 변경 로그 및 릴리스 도구에 아랍어 지원 추가
- 모든 문서에 대한 현대 표준 아랍어 번역 추가
- CLI에 로그아웃 명령어 추가
- 에이전트 기술 추가
- 계층적 메모리 격리를 위한 자동 root_scope 구현
- OpenAI 호환 네이티브 제공자 구현 (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### 버그 수정
- 트레이스 배치 푸시에 대한 잘못된 자격 증명 수정 (404)
- HITL 흐름 시스템의 여러 버그 해결
- 에이전트 메모리 저장 수정
- crewai 패키지 전반에 걸쳐 모든 엄격한 mypy 오류 해결
- FlowMeta의 listener+router 메서드에 대한 __router_paths__ 사용 수정
- 파일 지원이 없는 경우 값 오류 수정
- 문서에서 litellm 격리 단어 수정
- crewai-files의 모든 mypy 오류 수정 및 모든 패키지를 CI 유형 검사에 추가
- litellm의 상한을 마지막 테스트된 버전 (1.82.6)으로 고정
### 문서
- v1.12.0에 대한 변경 로그 및 버전 업데이트
- CONTRIBUTING.md 추가
- LiteLLM 없이 CrewAI를 사용하는 가이드 추가
## 기여자
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @nicoferdi96
</Update>
<Update label="2026년 3월 25일">
## v1.12.0
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0)
## 변경 사항
### 기능
- 메모리 시스템을 위한 Qdrant Edge 스토리지 백엔드 추가
- 변경 사항을 분석하고 번역된 문서와 함께 문서를 생성하는 docs-check 명령어 추가
- 변경 로그 및 릴리스 도구에 아랍어 지원 추가
- 모든 문서의 현대 표준 아랍어 번역 추가
- CLI에 로그아웃 명령어 추가
- 에이전트 기술 구현
- 계층적 메모리 격리를 위한 자동 root_scope 구현
- OpenAI 호환 네이티브 제공자 구현 (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### 버그 수정
- 트레이스 배치 푸시에 대한 잘못된 자격 증명 수정 (404)
- HITL 흐름 시스템의 여러 버그 해결
- crewai-files의 mypy 오류 해결 및 모든 패키지를 CI 타입 검사에 추가
- crewai-tools 패키지 전반의 모든 엄격한 mypy 오류 해결
- crewai 패키지 전반의 모든 mypy 오류 해결
- 에이전트의 메모리 절약 수정
- FlowMeta에서 listener+router 메서드의 __router_paths__ 사용 수정
- 파일 지원이 없을 때 값 오류 발생
- 문서에서 litellm 격리 단어 수정
- 인간 피드백 학습에서 메모리에 대한 isinstance 대신 None 체크 사용
- litellm의 상한을 마지막 테스트된 버전(1.82.6)으로 고정
### 문서
- v1.12.0에 대한 변경 로그 및 버전 업데이트
- CONTRIBUTING.md 추가
- LiteLLM 없이 CrewAI를 사용하는 가이드 추가
### 리팩토링
- 에이전트에서 동기/비동기 작업 실행 및 시작을 중복 제거하도록 리팩토링
- litellm의 내부 플러밍 단순화 (토큰 카운팅, 콜백, 기능 감지, 오류)
## 기여자
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @nicoferdi96
</Update>
<Update label="2026년 3월 26일">
## v1.12.0a3
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0a3)
## 변경 사항
### 버그 수정
- 트레이스 배치 푸시에 대한 잘못된 자격 증명 수정 (404)
- HITL 흐름 시스템의 여러 버그 해결
### 문서
- v1.12.0a2에 대한 변경 로그 및 버전 업데이트
## 기여자
@akaKuruma, @greysonlalonde
</Update>
<Update label="2026년 3월 25일">
## v1.12.0a2

View File

@@ -4,6 +4,134 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="25 mar 2026">
## v1.12.2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.2)
## O que Mudou
### Recursos
- Adicionar fase de lançamento empresarial ao lançamento do devtools
### Correções de Bugs
- Preservar o valor de retorno do método como saída de fluxo para @human_feedback com emit
### Documentação
- Atualizar changelog e versão para v1.12.1
- Revisar política de segurança e instruções de relatório
## Contributors
@alex-clawd, @greysonlalonde, @joaomdmoura, @theCyberTech
</Update>
<Update label="25 mar 2026">
## v1.12.1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.1)
## O que Mudou
### Recursos
- Adicionar request_id ao HumanFeedbackRequestedEvent
- Adicionar backend de armazenamento Qdrant Edge para sistema de memória
- Adicionar comando docs-check para analisar mudanças e gerar documentação com traduções
- Adicionar suporte ao idioma árabe para changelog e ferramentas de lançamento
- Adicionar tradução em árabe padrão moderno de toda a documentação
- Adicionar comando de logout na CLI
- Adicionar habilidades de agente
- Implementar root_scope automático para isolamento hierárquico de memória
- Implementar provedores nativos compatíveis com OpenAI (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### Correções de Bugs
- Corrigir credenciais incorretas para envio em lote de traces (404)
- Resolver múltiplos bugs no sistema de fluxo HITL
- Corrigir salvamento de memória do agente
- Resolver todos os erros estritos do mypy no pacote crewai
- Corrigir uso de __router_paths__ para métodos listener+router em FlowMeta
- Corrigir erro de valor em caso de suporte a nenhum arquivo
- Corrigir redação da quarentena do litellm na documentação
- Corrigir todos os erros do mypy em crewai-files e adicionar todos os pacotes às verificações de tipo do CI
- Fixar limite superior do litellm na última versão testada (1.82.6)
### Documentação
- Atualizar changelog e versão para v1.12.0
- Adicionar CONTRIBUTING.md
- Adicionar guia para usar CrewAI sem LiteLLM
## Contribuidores
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @nicoferdi96
</Update>
<Update label="25 mar 2026">
## v1.12.0
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0)
## O que Mudou
### Funcionalidades
- Adicionar backend de armazenamento Qdrant Edge para sistema de memória
- Adicionar comando docs-check para analisar mudanças e gerar documentos com traduções
- Adicionar suporte ao idioma árabe para changelog e ferramentas de lançamento
- Adicionar tradução em árabe padrão moderno de toda a documentação
- Adicionar comando de logout na CLI
- Implementar habilidades de agente
- Implementar root_scope automático para isolamento hierárquico de memória
- Implementar provedores nativos compatíveis com OpenAI (OpenRouter, DeepSeek, Ollama, vLLM, Cerebras, Dashscope)
### Correções de Bugs
- Corrigir credenciais inválidas para envio em lote de rastros (404)
- Resolver múltiplos bugs no sistema de fluxo HITL
- Resolver erros do mypy em crewai-files e adicionar todos os pacotes às verificações de tipo do CI
- Resolver todos os erros estritos do mypy no pacote crewai-tools
- Resolver todos os erros do mypy no pacote crewai
- Corrigir economia de memória no agente
- Corrigir uso de __router_paths__ para métodos listener+router em FlowMeta
- Levantar erro de valor em caso de suporte a arquivos inexistente
- Corrigir a redação da quarentena do litellm na documentação
- Usar verificação de None em vez de isinstance para memória no aprendizado de feedback humano
- Fixar limite superior do litellm na última versão testada (1.82.6)
### Documentação
- Atualizar changelog e versão para v1.12.0
- Adicionar CONTRIBUTING.md
- Adicionar guia para usar CrewAI sem LiteLLM
### Refatoração
- Refatorar para desduplicar execução de tarefas síncronas/assíncronas e início no agente
- Simplificar a infraestrutura interna do litellm (contagem de tokens, callbacks, detecção de recursos, erros)
## Contribuidores
@akaKuruma, @alex-clawd, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @nicoferdi96
</Update>
<Update label="26 mar 2026">
## v1.12.0a3
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.12.0a3)
## O que Mudou
### Correções de Bugs
- Corrigir credenciais inválidas para envio em lote de rastros (404)
- Resolver múltiplos bugs no sistema de fluxo HITL
### Documentação
- Atualizar changelog e versão para v1.12.0a2
## Contributors
@akaKuruma, @greysonlalonde
</Update>
<Update label="25 mar 2026">
## v1.12.0a2

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.12.0a2"
__version__ = "1.12.2"

View File

@@ -11,7 +11,7 @@ dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.12.0a2",
"crewai==1.12.2",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -309,4 +309,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.12.0a2"
__version__ = "1.12.2"

View File

@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.12.0a2",
"crewai-tools==1.12.2",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -42,7 +42,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.12.0a2"
__version__ = "1.12.2"
_telemetry_submitted = False

View File

@@ -0,0 +1,72 @@
"""A2UI (Agent to UI) declarative UI protocol support for CrewAI."""
from crewai.a2a.extensions.a2ui.catalog import (
AudioPlayer,
Button,
Card,
CheckBox,
Column,
DateTimeInput,
Divider,
Icon,
Image,
List,
Modal,
MultipleChoice,
Row,
Slider,
Tabs,
Text,
TextField,
Video,
)
from crewai.a2a.extensions.a2ui.client_extension import A2UIClientExtension
from crewai.a2a.extensions.a2ui.models import (
A2UIEvent,
A2UIMessage,
A2UIResponse,
BeginRendering,
DataModelUpdate,
DeleteSurface,
SurfaceUpdate,
UserAction,
)
from crewai.a2a.extensions.a2ui.server_extension import A2UIServerExtension
from crewai.a2a.extensions.a2ui.validator import (
validate_a2ui_event,
validate_a2ui_message,
)
__all__ = [
"A2UIClientExtension",
"A2UIEvent",
"A2UIMessage",
"A2UIResponse",
"A2UIServerExtension",
"AudioPlayer",
"BeginRendering",
"Button",
"Card",
"CheckBox",
"Column",
"DataModelUpdate",
"DateTimeInput",
"DeleteSurface",
"Divider",
"Icon",
"Image",
"List",
"Modal",
"MultipleChoice",
"Row",
"Slider",
"SurfaceUpdate",
"Tabs",
"Text",
"TextField",
"UserAction",
"Video",
"validate_a2ui_event",
"validate_a2ui_message",
]

View File

@@ -0,0 +1,467 @@
"""Typed helpers for A2UI standard catalog components.
These models provide optional type safety for standard catalog components.
Agents can also use raw dicts validated against the JSON schema.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class StringBinding(BaseModel):
"""A string value: literal or data-model path."""
literal_string: str | None = Field(
default=None, alias="literalString", description="Literal string value."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class NumberBinding(BaseModel):
"""A numeric value: literal or data-model path."""
literal_number: float | None = Field(
default=None, alias="literalNumber", description="Literal numeric value."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class BooleanBinding(BaseModel):
"""A boolean value: literal or data-model path."""
literal_boolean: bool | None = Field(
default=None, alias="literalBoolean", description="Literal boolean value."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class ArrayBinding(BaseModel):
"""An array value: literal or data-model path."""
literal_array: list[str] | None = Field(
default=None, alias="literalArray", description="Literal array of strings."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class ChildrenDef(BaseModel):
"""Children definition for layout components."""
explicit_list: list[str] | None = Field(
default=None,
alias="explicitList",
description="Explicit list of child component IDs.",
)
template: ChildTemplate | None = Field(
default=None, description="Template for generating dynamic children."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class ChildTemplate(BaseModel):
"""Template for generating dynamic children from a data model list."""
component_id: str = Field(
alias="componentId", description="ID of the component to repeat."
)
data_binding: str = Field(
alias="dataBinding", description="Data-model path to bind the template to."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class ActionContextEntry(BaseModel):
"""A key-value pair in an action context payload."""
key: str = Field(description="Context entry key.")
value: ActionBoundValue = Field(description="Context entry value.")
model_config = ConfigDict(extra="forbid")
class ActionBoundValue(BaseModel):
"""A value in an action context: literal or data-model path."""
path: str | None = Field(default=None, description="Data-model path reference.")
literal_string: str | None = Field(
default=None, alias="literalString", description="Literal string value."
)
literal_number: float | None = Field(
default=None, alias="literalNumber", description="Literal numeric value."
)
literal_boolean: bool | None = Field(
default=None, alias="literalBoolean", description="Literal boolean value."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Action(BaseModel):
"""Client-side action dispatched by interactive components."""
name: str = Field(description="Action name dispatched on interaction.")
context: list[ActionContextEntry] | None = Field(
default=None, description="Key-value pairs sent with the action."
)
model_config = ConfigDict(extra="forbid")
class TabItem(BaseModel):
"""A single tab definition."""
title: StringBinding = Field(description="Tab title text.")
child: str = Field(description="Component ID rendered as the tab content.")
model_config = ConfigDict(extra="forbid")
class MultipleChoiceOption(BaseModel):
"""A single option in a MultipleChoice component."""
label: StringBinding = Field(description="Display label for the option.")
value: str = Field(description="Value submitted when the option is selected.")
model_config = ConfigDict(extra="forbid")
class Text(BaseModel):
"""Displays text content."""
text: StringBinding = Field(description="Text content to display.")
usage_hint: Literal["h1", "h2", "h3", "h4", "h5", "caption", "body"] | None = Field(
default=None, alias="usageHint", description="Semantic hint for text styling."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Image(BaseModel):
"""Displays an image."""
url: StringBinding = Field(description="Image source URL.")
fit: Literal["contain", "cover", "fill", "none", "scale-down"] | None = Field(
default=None, description="Object-fit behavior for the image."
)
usage_hint: (
Literal[
"icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"
]
| None
) = Field(
default=None, alias="usageHint", description="Semantic hint for image sizing."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
IconName = Literal[
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning",
]
class IconBinding(BaseModel):
"""Icon name: literal enum or data-model path."""
literal_string: IconName | None = Field(
default=None, alias="literalString", description="Literal icon name."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Icon(BaseModel):
"""Displays a named icon."""
name: IconBinding = Field(description="Icon name binding.")
model_config = ConfigDict(extra="forbid")
class Video(BaseModel):
"""Displays a video player."""
url: StringBinding = Field(description="Video source URL.")
model_config = ConfigDict(extra="forbid")
class AudioPlayer(BaseModel):
"""Displays an audio player."""
url: StringBinding = Field(description="Audio source URL.")
description: StringBinding | None = Field(
default=None, description="Accessible description of the audio content."
)
model_config = ConfigDict(extra="forbid")
class Row(BaseModel):
"""Horizontal layout container."""
children: ChildrenDef = Field(description="Child components in this row.")
distribution: (
Literal["center", "end", "spaceAround", "spaceBetween", "spaceEvenly", "start"]
| None
) = Field(
default=None, description="How children are distributed along the main axis."
)
alignment: Literal["start", "center", "end", "stretch"] | None = Field(
default=None, description="How children are aligned on the cross axis."
)
model_config = ConfigDict(extra="forbid")
class Column(BaseModel):
"""Vertical layout container."""
children: ChildrenDef = Field(description="Child components in this column.")
distribution: (
Literal["start", "center", "end", "spaceBetween", "spaceAround", "spaceEvenly"]
| None
) = Field(
default=None, description="How children are distributed along the main axis."
)
alignment: Literal["center", "end", "start", "stretch"] | None = Field(
default=None, description="How children are aligned on the cross axis."
)
model_config = ConfigDict(extra="forbid")
class List(BaseModel):
"""Scrollable list container."""
children: ChildrenDef = Field(description="Child components in this list.")
direction: Literal["vertical", "horizontal"] | None = Field(
default=None, description="Scroll direction of the list."
)
alignment: Literal["start", "center", "end", "stretch"] | None = Field(
default=None, description="How children are aligned on the cross axis."
)
model_config = ConfigDict(extra="forbid")
class Card(BaseModel):
"""Card container wrapping a single child."""
child: str = Field(description="Component ID of the card content.")
model_config = ConfigDict(extra="forbid")
class Tabs(BaseModel):
"""Tabbed navigation container."""
tab_items: list[TabItem] = Field(
alias="tabItems", description="List of tab definitions."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Divider(BaseModel):
"""A visual divider line."""
axis: Literal["horizontal", "vertical"] | None = Field(
default=None, description="Orientation of the divider."
)
model_config = ConfigDict(extra="forbid")
class Modal(BaseModel):
"""A modal dialog with an entry point trigger and content."""
entry_point_child: str = Field(
alias="entryPointChild", description="Component ID that triggers the modal."
)
content_child: str = Field(
alias="contentChild", description="Component ID rendered inside the modal."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Button(BaseModel):
"""An interactive button with an action."""
child: str = Field(description="Component ID of the button label.")
primary: bool | None = Field(
default=None, description="Whether the button uses primary styling."
)
action: Action = Field(description="Action dispatched when the button is clicked.")
model_config = ConfigDict(extra="forbid")
class CheckBox(BaseModel):
"""A checkbox input."""
label: StringBinding = Field(description="Label text for the checkbox.")
value: BooleanBinding = Field(
description="Boolean value binding for the checkbox state."
)
model_config = ConfigDict(extra="forbid")
class TextField(BaseModel):
"""A text input field."""
label: StringBinding = Field(description="Label text for the input.")
text: StringBinding | None = Field(
default=None, description="Current text value binding."
)
text_field_type: (
Literal["date", "longText", "number", "shortText", "obscured"] | None
) = Field(default=None, alias="textFieldType", description="Input type variant.")
validation_regexp: str | None = Field(
default=None,
alias="validationRegexp",
description="Regex pattern for client-side validation.",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class DateTimeInput(BaseModel):
"""A date and/or time picker."""
value: StringBinding = Field(description="ISO date/time string value binding.")
enable_date: bool | None = Field(
default=None,
alias="enableDate",
description="Whether the date picker is enabled.",
)
enable_time: bool | None = Field(
default=None,
alias="enableTime",
description="Whether the time picker is enabled.",
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class MultipleChoice(BaseModel):
"""A multiple-choice selection component."""
selections: ArrayBinding = Field(description="Array binding for selected values.")
options: list[MultipleChoiceOption] = Field(description="Available choices.")
max_allowed_selections: int | None = Field(
default=None,
alias="maxAllowedSelections",
description="Maximum number of selections allowed.",
)
variant: Literal["checkbox", "chips"] | None = Field(
default=None, description="Visual variant for the selection UI."
)
filterable: bool | None = Field(
default=None, description="Whether options can be filtered by typing."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class Slider(BaseModel):
"""A numeric slider input."""
value: NumberBinding = Field(
description="Numeric value binding for the slider position."
)
min_value: float | None = Field(
default=None, alias="minValue", description="Minimum slider value."
)
max_value: float | None = Field(
default=None, alias="maxValue", description="Maximum slider value."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
STANDARD_CATALOG_COMPONENTS: frozenset[str] = frozenset(
{
"Text",
"Image",
"Icon",
"Video",
"AudioPlayer",
"Row",
"Column",
"List",
"Card",
"Tabs",
"Divider",
"Modal",
"Button",
"CheckBox",
"TextField",
"DateTimeInput",
"MultipleChoice",
"Slider",
}
)

View File

@@ -0,0 +1,285 @@
"""A2UI client extension for the A2A protocol."""
from __future__ import annotations
from collections.abc import Sequence
import logging
from typing import TYPE_CHECKING, Any, cast
from pydantic import Field
from pydantic.dataclasses import dataclass
from typing_extensions import TypedDict
from crewai.a2a.extensions.a2ui.models import extract_a2ui_json_objects
from crewai.a2a.extensions.a2ui.prompt import build_a2ui_system_prompt
from crewai.a2a.extensions.a2ui.server_extension import A2UI_MIME_TYPE
from crewai.a2a.extensions.a2ui.validator import (
A2UIValidationError,
validate_a2ui_message,
)
if TYPE_CHECKING:
from a2a.types import Message
from crewai.agent.core import Agent
logger = logging.getLogger(__name__)
class StylesDict(TypedDict, total=False):
"""Serialized surface styling."""
font: str
primaryColor: str
class ComponentEntryDict(TypedDict, total=False):
"""Serialized component entry in a surface update."""
id: str
weight: float
component: dict[str, Any]
class BeginRenderingDict(TypedDict, total=False):
"""Serialized beginRendering payload."""
surfaceId: str
root: str
catalogId: str
styles: StylesDict
class SurfaceUpdateDict(TypedDict, total=False):
"""Serialized surfaceUpdate payload."""
surfaceId: str
components: list[ComponentEntryDict]
class DataEntryDict(TypedDict, total=False):
"""Serialized data model entry."""
key: str
valueString: str
valueNumber: float
valueBoolean: bool
valueMap: list[DataEntryDict]
class DataModelUpdateDict(TypedDict, total=False):
"""Serialized dataModelUpdate payload."""
surfaceId: str
path: str
contents: list[DataEntryDict]
class DeleteSurfaceDict(TypedDict):
"""Serialized deleteSurface payload."""
surfaceId: str
class A2UIMessageDict(TypedDict, total=False):
"""Serialized A2UI server-to-client message with exactly one key set."""
beginRendering: BeginRenderingDict
surfaceUpdate: SurfaceUpdateDict
dataModelUpdate: DataModelUpdateDict
deleteSurface: DeleteSurfaceDict
@dataclass
class A2UIConversationState:
"""Tracks active A2UI surfaces and data models across a conversation."""
active_surfaces: dict[str, dict[str, Any]] = Field(default_factory=dict)
data_models: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
last_a2ui_messages: list[A2UIMessageDict] = Field(default_factory=list)
def is_ready(self) -> bool:
"""Return True when at least one surface is active."""
return bool(self.active_surfaces)
class A2UIClientExtension:
"""A2A client extension that adds A2UI support to agents.
Implements the ``A2AExtension`` protocol to inject A2UI prompt
instructions, track UI state across conversations, and validate
A2UI messages in responses.
Example::
A2AClientConfig(
endpoint="...",
extensions=["https://a2ui.org/a2a-extension/a2ui/v0.8"],
client_extensions=[A2UIClientExtension()],
)
"""
def __init__(
self,
catalog_id: str | None = None,
allowed_components: list[str] | None = None,
) -> None:
"""Initialize the A2UI client extension.
Args:
catalog_id: Catalog identifier to use for prompt generation.
allowed_components: Subset of component names to expose to the agent.
"""
self._catalog_id = catalog_id
self._allowed_components = allowed_components
def inject_tools(self, agent: Agent) -> None:
"""No-op — A2UI uses prompt augmentation rather than tool injection."""
def extract_state_from_history(
self, conversation_history: Sequence[Message]
) -> A2UIConversationState | None:
"""Scan conversation history for A2UI DataParts and track surface state.
When ``catalog_id`` is set, only surfaces matching that catalog are tracked.
"""
state = A2UIConversationState()
for message in conversation_history:
for part in message.parts:
root = part.root
if root.kind != "data":
continue
metadata = root.metadata or {}
mime_type = metadata.get("mimeType", "")
if mime_type != A2UI_MIME_TYPE:
continue
data = root.data
if not isinstance(data, dict):
continue
surface_id = _get_surface_id(data)
if not surface_id:
continue
if self._catalog_id and "beginRendering" in data:
catalog_id = data["beginRendering"].get("catalogId")
if catalog_id and catalog_id != self._catalog_id:
continue
if "deleteSurface" in data:
state.active_surfaces.pop(surface_id, None)
state.data_models.pop(surface_id, None)
elif "beginRendering" in data:
state.active_surfaces[surface_id] = data["beginRendering"]
elif "surfaceUpdate" in data:
state.active_surfaces[surface_id] = data["surfaceUpdate"]
elif "dataModelUpdate" in data:
contents = data["dataModelUpdate"].get("contents", [])
state.data_models.setdefault(surface_id, []).extend(contents)
if not state.active_surfaces and not state.data_models:
return None
return state
def augment_prompt(
self,
base_prompt: str,
_conversation_state: A2UIConversationState | None,
) -> str:
"""Append A2UI system prompt instructions to the base prompt."""
a2ui_prompt = build_a2ui_system_prompt(
catalog_id=self._catalog_id,
allowed_components=self._allowed_components,
)
return f"{base_prompt}\n\n{a2ui_prompt}"
def process_response(
self,
agent_response: Any,
conversation_state: A2UIConversationState | None,
) -> Any:
"""Extract and validate A2UI JSON from agent output.
When ``allowed_components`` is set, components not in the allowlist are
logged and stripped from surface updates. Stores extracted A2UI messages
on the conversation state and returns the original response unchanged.
"""
text = (
agent_response if isinstance(agent_response, str) else str(agent_response)
)
a2ui_messages = _extract_and_validate(text)
if self._allowed_components:
allowed = set(self._allowed_components)
a2ui_messages = [_filter_components(msg, allowed) for msg in a2ui_messages]
if a2ui_messages and conversation_state is not None:
conversation_state.last_a2ui_messages = a2ui_messages
return agent_response
def _get_surface_id(data: dict[str, Any]) -> str | None:
"""Extract surfaceId from any A2UI message type."""
for key in ("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"):
inner = data.get(key)
if isinstance(inner, dict):
sid = inner.get("surfaceId")
if isinstance(sid, str):
return sid
return None
def _filter_components(msg: A2UIMessageDict, allowed: set[str]) -> A2UIMessageDict:
"""Strip components whose type is not in *allowed* from a surfaceUpdate."""
surface_update = msg.get("surfaceUpdate")
if not isinstance(surface_update, dict):
return msg
components = surface_update.get("components")
if not isinstance(components, list):
return msg
filtered = []
for entry in components:
component = entry.get("component", {})
component_types = set(component.keys())
disallowed = component_types - allowed
if disallowed:
logger.debug(
"Stripping disallowed component type(s) %s from surface update",
disallowed,
)
continue
filtered.append(entry)
if len(filtered) == len(components):
return msg
return {**msg, "surfaceUpdate": {**surface_update, "components": filtered}}
def _extract_and_validate(text: str) -> list[A2UIMessageDict]:
"""Extract A2UI JSON objects from text and validate them."""
return [
dumped
for candidate in extract_a2ui_json_objects(text)
if (dumped := _try_validate(candidate)) is not None
]
def _try_validate(candidate: dict[str, Any]) -> A2UIMessageDict | None:
"""Validate a single A2UI candidate, returning None on failure."""
try:
msg = validate_a2ui_message(candidate)
except A2UIValidationError:
logger.debug(
"Skipping invalid A2UI candidate in agent output",
exc_info=True,
)
return None
return cast(A2UIMessageDict, msg.model_dump(by_alias=True, exclude_none=True))

View File

@@ -0,0 +1,281 @@
"""Pydantic models for A2UI server-to-client messages and client-to-server events."""
from __future__ import annotations
import json
import re
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
class BoundValue(BaseModel):
"""A value that can be a literal or a data-model path reference."""
literal_string: str | None = Field(
default=None, alias="literalString", description="Literal string value."
)
literal_number: float | None = Field(
default=None, alias="literalNumber", description="Literal numeric value."
)
literal_boolean: bool | None = Field(
default=None, alias="literalBoolean", description="Literal boolean value."
)
literal_array: list[str] | None = Field(
default=None, alias="literalArray", description="Literal array of strings."
)
path: str | None = Field(default=None, description="Data-model path reference.")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class MapEntry(BaseModel):
"""A single entry in a valueMap adjacency list, supporting recursive nesting."""
key: str = Field(description="Entry key.")
value_string: str | None = Field(
default=None, alias="valueString", description="String value."
)
value_number: float | None = Field(
default=None, alias="valueNumber", description="Numeric value."
)
value_boolean: bool | None = Field(
default=None, alias="valueBoolean", description="Boolean value."
)
value_map: list[MapEntry] | None = Field(
default=None, alias="valueMap", description="Nested map entries."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class DataEntry(BaseModel):
"""A data model entry with a key and exactly one typed value."""
key: str = Field(description="Entry key.")
value_string: str | None = Field(
default=None, alias="valueString", description="String value."
)
value_number: float | None = Field(
default=None, alias="valueNumber", description="Numeric value."
)
value_boolean: bool | None = Field(
default=None, alias="valueBoolean", description="Boolean value."
)
value_map: list[MapEntry] | None = Field(
default=None, alias="valueMap", description="Nested map entries."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
_HEX_COLOR_PATTERN: re.Pattern[str] = re.compile(r"^#[0-9a-fA-F]{6}$")
class Styles(BaseModel):
"""Surface styling information."""
font: str | None = Field(default=None, description="Font family name.")
primary_color: str | None = Field(
default=None,
alias="primaryColor",
pattern=_HEX_COLOR_PATTERN.pattern,
description="Primary color as a hex string.",
)
model_config = ConfigDict(populate_by_name=True, extra="allow")
class ComponentEntry(BaseModel):
"""A single component in a UI widget tree.
The ``component`` dict must contain exactly one key — the component type
name (e.g. ``"Text"``, ``"Button"``) — whose value holds the component
properties. Component internals are left as ``dict[str, Any]`` because
they are catalog-dependent; use the typed helpers in ``catalog.py`` for
the standard catalog.
"""
id: str = Field(description="Unique component identifier.")
weight: float | None = Field(
default=None, description="Flex weight for layout distribution."
)
component: dict[str, Any] = Field(
description="Component type name mapped to its properties."
)
model_config = ConfigDict(extra="forbid")
class BeginRendering(BaseModel):
"""Signals the client to begin rendering a surface."""
surface_id: str = Field(alias="surfaceId", description="Unique surface identifier.")
root: str = Field(description="Component ID of the root element.")
catalog_id: str | None = Field(
default=None,
alias="catalogId",
description="Catalog identifier for the surface.",
)
styles: Styles | None = Field(
default=None, description="Surface styling overrides."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class SurfaceUpdate(BaseModel):
"""Updates a surface with a new set of components."""
surface_id: str = Field(alias="surfaceId", description="Target surface identifier.")
components: list[ComponentEntry] = Field(
min_length=1, description="Components to render on the surface."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class DataModelUpdate(BaseModel):
"""Updates the data model for a surface."""
surface_id: str = Field(alias="surfaceId", description="Target surface identifier.")
path: str | None = Field(
default=None, description="Data-model path prefix for the update."
)
contents: list[DataEntry] = Field(
description="Data entries to merge into the model."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class DeleteSurface(BaseModel):
"""Signals the client to delete a surface."""
surface_id: str = Field(
alias="surfaceId", description="Surface identifier to delete."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
class A2UIMessage(BaseModel):
"""Union wrapper for the four server-to-client A2UI message types.
Exactly one of the fields must be set.
"""
begin_rendering: BeginRendering | None = Field(
default=None,
alias="beginRendering",
description="Begin rendering a new surface.",
)
surface_update: SurfaceUpdate | None = Field(
default=None,
alias="surfaceUpdate",
description="Update components on a surface.",
)
data_model_update: DataModelUpdate | None = Field(
default=None,
alias="dataModelUpdate",
description="Update the surface data model.",
)
delete_surface: DeleteSurface | None = Field(
default=None, alias="deleteSurface", description="Delete an existing surface."
)
model_config = ConfigDict(populate_by_name=True, extra="forbid")
@model_validator(mode="after")
def _check_exactly_one(self) -> A2UIMessage:
"""Enforce the spec's exactly-one-of constraint."""
fields = [
self.begin_rendering,
self.surface_update,
self.data_model_update,
self.delete_surface,
]
count = sum(f is not None for f in fields)
if count != 1:
raise ValueError(f"Exactly one A2UI message type must be set, got {count}")
return self
class UserAction(BaseModel):
"""Reports a user-initiated action from a component."""
name: str = Field(description="Action name.")
surface_id: str = Field(alias="surfaceId", description="Source surface identifier.")
source_component_id: str = Field(
alias="sourceComponentId", description="Component that triggered the action."
)
timestamp: str = Field(description="ISO 8601 timestamp of the action.")
context: dict[str, Any] = Field(description="Action context payload.")
model_config = ConfigDict(populate_by_name=True)
class ClientError(BaseModel):
"""Reports a client-side error."""
model_config = ConfigDict(extra="allow")
class A2UIEvent(BaseModel):
"""Union wrapper for client-to-server events."""
user_action: UserAction | None = Field(
default=None, alias="userAction", description="User-initiated action event."
)
error: ClientError | None = Field(
default=None, description="Client-side error report."
)
model_config = ConfigDict(populate_by_name=True)
@model_validator(mode="after")
def _check_exactly_one(self) -> A2UIEvent:
"""Enforce the spec's exactly-one-of constraint."""
fields = [self.user_action, self.error]
count = sum(f is not None for f in fields)
if count != 1:
raise ValueError(f"Exactly one A2UI event type must be set, got {count}")
return self
class A2UIResponse(BaseModel):
"""Typed wrapper for responses containing A2UI messages."""
text: str = Field(description="Raw text content of the response.")
a2ui_parts: list[dict[str, Any]] = Field(
default_factory=list, description="A2UI DataParts extracted from the response."
)
a2ui_messages: list[dict[str, Any]] = Field(
default_factory=list, description="Validated A2UI message dicts."
)
_A2UI_KEYS = {"beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"}
def extract_a2ui_json_objects(text: str) -> list[dict[str, Any]]:
"""Extract JSON objects containing A2UI keys from text.
Uses ``json.JSONDecoder.raw_decode`` for robust parsing that correctly
handles braces inside string literals.
"""
decoder = json.JSONDecoder()
results: list[dict[str, Any]] = []
idx = 0
while idx < len(text):
idx = text.find("{", idx)
if idx == -1:
break
try:
obj, end_idx = decoder.raw_decode(text, idx)
if isinstance(obj, dict) and _A2UI_KEYS & obj.keys():
results.append(obj)
idx = end_idx
except json.JSONDecodeError:
idx += 1
return results

View File

@@ -0,0 +1,76 @@
"""System prompt generation for A2UI-capable agents."""
from __future__ import annotations
import json
from crewai.a2a.extensions.a2ui.catalog import STANDARD_CATALOG_COMPONENTS
from crewai.a2a.extensions.a2ui.schema import load_schema
from crewai.a2a.extensions.a2ui.server_extension import A2UI_EXTENSION_URI
def build_a2ui_system_prompt(
catalog_id: str | None = None,
allowed_components: list[str] | None = None,
) -> str:
"""Build a system prompt fragment instructing the LLM to produce A2UI output.
The prompt describes the A2UI message format, available components, and
data binding rules. It includes the resolved schema so the LLM can
generate structured output.
Args:
catalog_id: Catalog identifier to reference. Defaults to the
standard catalog version derived from ``A2UI_EXTENSION_URI``.
allowed_components: Subset of component names to expose. When
``None``, all standard catalog components are available.
Returns:
A system prompt string to append to the agent's instructions.
"""
components = sorted(
allowed_components
if allowed_components is not None
else STANDARD_CATALOG_COMPONENTS
)
catalog_label = catalog_id or f"standard ({A2UI_EXTENSION_URI.rsplit('/', 1)[-1]})"
resolved_schema = load_schema("server_to_client_with_standard_catalog")
schema_json = json.dumps(resolved_schema, indent=2)
return f"""\
<A2UI_INSTRUCTIONS>
You can generate rich, declarative UI by emitting A2UI JSON messages.
CATALOG: {catalog_label}
AVAILABLE COMPONENTS: {", ".join(components)}
MESSAGE TYPES (emit exactly ONE per message):
- beginRendering: Initialize a new surface with a root component and optional styles.
- surfaceUpdate: Send/update components for a surface. Each component has a unique id \
and a "component" wrapper containing exactly one component-type key.
- dataModelUpdate: Update the data model for a surface. Data entries have a key and \
one typed value (valueString, valueNumber, valueBoolean, valueMap).
- deleteSurface: Remove a surface.
DATA BINDING:
- Use {{"literalString": "..."}} for inline string values.
- Use {{"literalNumber": ...}} for inline numeric values.
- Use {{"literalBoolean": ...}} for inline boolean values.
- Use {{"literalArray": ["...", "..."]}} for inline array values.
- Use {{"path": "/data/model/path"}} to bind to data model values.
ACTIONS:
- Interactive components (Button, etc.) have an "action" with a "name" and optional \
"context" array of key/value pairs.
- Values in action context can use data binding (path or literal).
OUTPUT FORMAT:
Emit each A2UI message as a valid JSON object. When generating UI, produce a \
beginRendering message first, then surfaceUpdate messages with components, and \
optionally dataModelUpdate messages to populate data-bound values.
SCHEMA:
{schema_json}
</A2UI_INSTRUCTIONS>"""

View File

@@ -0,0 +1,48 @@
"""Schema loading utilities for vendored A2UI JSON schemas."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
_SCHEMA_DIR = Path(__file__).parent / "v0_8"
_SCHEMA_CACHE: dict[str, dict[str, Any]] = {}
SCHEMA_NAMES: frozenset[str] = frozenset(
{
"server_to_client",
"client_to_server",
"standard_catalog_definition",
"server_to_client_with_standard_catalog",
}
)
def load_schema(name: str) -> dict[str, Any]:
"""Load a vendored A2UI JSON schema by name.
Args:
name: Schema name without extension (e.g. ``"server_to_client"``).
Returns:
Parsed JSON schema dict.
Raises:
ValueError: If the schema name is not recognized.
FileNotFoundError: If the schema file is missing from the package.
"""
if name not in SCHEMA_NAMES:
raise ValueError(f"Unknown schema {name!r}. Available: {sorted(SCHEMA_NAMES)}")
if name in _SCHEMA_CACHE:
return _SCHEMA_CACHE[name]
path = _SCHEMA_DIR / f"{name}.json"
with path.open() as f:
schema: dict[str, Any] = json.load(f)
_SCHEMA_CACHE[name] = schema
return schema

View File

@@ -0,0 +1,53 @@
{
"title": "A2UI (Agent to UI) Client-to-Server Event Schema",
"description": "Describes a JSON payload for a client-to-server event message.",
"type": "object",
"minProperties": 1,
"maxProperties": 1,
"properties": {
"userAction": {
"type": "object",
"description": "Reports a user-initiated action from a component.",
"properties": {
"name": {
"type": "string",
"description": "The name of the action, taken from the component's action.name property."
},
"surfaceId": {
"type": "string",
"description": "The id of the surface where the event originated."
},
"sourceComponentId": {
"type": "string",
"description": "The id of the component that triggered the event."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "An ISO 8601 timestamp of when the event occurred."
},
"context": {
"type": "object",
"description": "A JSON object containing the key-value pairs from the component's action.context, after resolving all data bindings.",
"additionalProperties": true
}
},
"required": [
"name",
"surfaceId",
"sourceComponentId",
"timestamp",
"context"
]
},
"error": {
"type": "object",
"description": "Reports a client-side error. The content is flexible.",
"additionalProperties": true
}
},
"oneOf": [
{ "required": ["userAction"] },
{ "required": ["error"] }
]
}

View File

@@ -0,0 +1,148 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"additionalProperties": false,
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"catalogId": {
"type": "string",
"description": "The identifier of the component catalog to use for this surface. If omitted, the client MUST default to the standard catalog for this A2UI version (https://a2ui.org/specification/v0_8/standard_catalog_definition.json)."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"additionalProperties": true
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type. The value is an object containing the properties for that specific component.",
"additionalProperties": true
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}

View File

@@ -0,0 +1,832 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"additionalProperties": false,
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"additionalProperties": false,
"properties": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.",
"additionalProperties": false,
"properties": {
"Text": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.",
"enum": [
"h1",
"h2",
"h3",
"h4",
"h5",
"caption",
"body"
]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": [
"contain",
"cover",
"fill",
"none",
"scale-down"
]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning"
]
},
"path": {
"type": "string"
}
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"additionalProperties": false,
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "object",
"description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"child": {
"type": "string"
}
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"additionalProperties": false,
"properties": {
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"additionalProperties": false,
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button)."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button, typically a Text component."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"context": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "object",
"description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"path": {
"type": "string"
},
"literalString": {
"type": "string"
},
"literalNumber": {
"type": "number"
},
"literalBoolean": {
"type": "boolean"
}
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "object",
"description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').",
"additionalProperties": false,
"properties": {
"literalBoolean": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"text": {
"type": "object",
"description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"textFieldType": {
"type": "string",
"description": "The type of input field to display.",
"enum": [
"date",
"longText",
"number",
"shortText",
"obscured"
]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
}
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"additionalProperties": false,
"properties": {
"selections": {
"type": "object",
"description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').",
"additionalProperties": false,
"properties": {
"literalArray": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "string"
}
}
},
"options": {
"type": "array",
"description": "An array of available options for the user to choose from.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "string",
"description": "The value to be associated with this option when selected."
}
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": {
"type": "integer",
"description": "The maximum number of options that the user is allowed to select."
},
"variant": {
"type": "string",
"enum": ["checkbox", "chips"],
"description": "The visual variant for the selection UI."
},
"filterable": {
"type": "boolean",
"description": "Whether options can be filtered by typing."
}
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
"additionalProperties": false,
"properties": {
"literalNumber": {
"type": "number"
},
"path": {
"type": "string"
}
}
},
"minValue": {
"type": "number",
"description": "The minimum value of the slider."
},
"maxValue": {
"type": "number",
"description": "The maximum value of the slider."
}
},
"required": ["value"]
}
}
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"additionalProperties": false,
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"additionalProperties": false,
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}

View File

@@ -0,0 +1,459 @@
{
"components": {
"Text": {
"type": "object",
"additionalProperties": false,
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style.",
"enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display.",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container.",
"enum": ["contain", "cover", "fill", "none", "scale-down"]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style.",
"enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display.",
"additionalProperties": false,
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle", "add", "arrowBack", "arrowForward", "attachFile",
"calendarToday", "call", "camera", "check", "close", "delete",
"download", "edit", "event", "error", "favorite", "favoriteOff",
"folder", "help", "home", "info", "locationOn", "lock", "lockOpen",
"mail", "menu", "moreVert", "moreHoriz", "notificationsOff",
"notifications", "payment", "person", "phone", "photo", "print",
"refresh", "search", "send", "settings", "share", "shoppingCart",
"star", "starHalf", "starOff", "upload", "visibility",
"visibilityOff", "warning"
]
},
"path": { "type": "string" }
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display.",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played.",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary.",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": { "type": "array", "items": { "type": "string" } },
"template": {
"type": "object",
"additionalProperties": false,
"properties": {
"componentId": { "type": "string" },
"dataBinding": { "type": "string" }
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"enum": ["center", "end", "spaceAround", "spaceBetween", "spaceEvenly", "start"]
},
"alignment": {
"type": "string",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": { "type": "array", "items": { "type": "string" } },
"template": {
"type": "object",
"additionalProperties": false,
"properties": {
"componentId": { "type": "string" },
"dataBinding": { "type": "string" }
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"enum": ["start", "center", "end", "spaceBetween", "spaceAround", "spaceEvenly"]
},
"alignment": {
"type": "string",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"additionalProperties": false,
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"additionalProperties": false,
"properties": {
"explicitList": { "type": "array", "items": { "type": "string" } },
"template": {
"type": "object",
"additionalProperties": false,
"properties": {
"componentId": { "type": "string" },
"dataBinding": { "type": "string" }
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"tabItems": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"child": { "type": "string" }
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"additionalProperties": false,
"properties": {
"axis": {
"type": "string",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"additionalProperties": false,
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"context": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": { "type": "string" },
"value": {
"type": "object",
"additionalProperties": false,
"properties": {
"path": { "type": "string" },
"literalString": { "type": "string" },
"literalNumber": { "type": "number" },
"literalBoolean": { "type": "boolean" }
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"value": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalBoolean": { "type": "boolean" },
"path": { "type": "string" }
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"text": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"textFieldType": {
"type": "string",
"enum": ["date", "longText", "number", "shortText", "obscured"]
},
"validationRegexp": { "type": "string" }
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"enableDate": { "type": "boolean" },
"enableTime": { "type": "boolean" }
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"additionalProperties": false,
"properties": {
"selections": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalArray": { "type": "array", "items": { "type": "string" } },
"path": { "type": "string" }
}
},
"options": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalString": { "type": "string" },
"path": { "type": "string" }
}
},
"value": { "type": "string" }
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": { "type": "integer" },
"variant": {
"type": "string",
"enum": ["checkbox", "chips"]
},
"filterable": { "type": "boolean" }
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "object",
"additionalProperties": false,
"properties": {
"literalNumber": { "type": "number" },
"path": { "type": "string" }
}
},
"minValue": { "type": "number" },
"maxValue": { "type": "number" }
},
"required": ["value"]
}
},
"styles": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}

View File

@@ -0,0 +1,125 @@
"""A2UI server extension for the A2A protocol."""
from __future__ import annotations
import logging
from typing import Any
from crewai.a2a.extensions.a2ui.models import A2UIResponse, extract_a2ui_json_objects
from crewai.a2a.extensions.a2ui.validator import (
A2UIValidationError,
validate_a2ui_message,
)
from crewai.a2a.extensions.server import ExtensionContext, ServerExtension
logger = logging.getLogger(__name__)
A2UI_MIME_TYPE = "application/json+a2ui"
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.8"
class A2UIServerExtension(ServerExtension):
"""A2A server extension that enables A2UI declarative UI generation.
When activated by a client that declares A2UI v0.8 support,
this extension:
* Negotiates catalog preferences during ``on_request``.
* Wraps A2UI messages in the agent response as A2A DataParts with
``application/json+a2ui`` MIME type during ``on_response``.
Example::
A2AServerConfig(
server_extensions=[A2UIServerExtension()],
default_output_modes=["text/plain", "application/json+a2ui"],
)
"""
uri: str = A2UI_EXTENSION_URI
required: bool = False
description: str = "A2UI declarative UI generation"
def __init__(
self,
catalog_ids: list[str] | None = None,
accept_inline_catalogs: bool = False,
) -> None:
"""Initialize the A2UI server extension.
Args:
catalog_ids: Catalog identifiers this server supports.
accept_inline_catalogs: Whether inline catalog definitions are accepted.
"""
self._catalog_ids = catalog_ids or []
self._accept_inline_catalogs = accept_inline_catalogs
@property
def params(self) -> dict[str, Any]:
"""Extension parameters advertised in the AgentCard."""
result: dict[str, Any] = {}
if self._catalog_ids:
result["supportedCatalogIds"] = self._catalog_ids
result["acceptsInlineCatalogs"] = self._accept_inline_catalogs
return result
async def on_request(self, context: ExtensionContext) -> None:
"""Extract A2UI catalog preferences from the client request.
Stores the negotiated catalog in ``context.state`` under
``"a2ui_catalog_id"`` for downstream use.
"""
if not self.is_active(context):
return
catalog_id = context.get_extension_metadata(self.uri, "catalogId")
if isinstance(catalog_id, str):
context.state["a2ui_catalog_id"] = catalog_id
elif self._catalog_ids:
context.state["a2ui_catalog_id"] = self._catalog_ids[0]
context.state["a2ui_active"] = True
async def on_response(self, context: ExtensionContext, result: Any) -> Any:
"""Wrap A2UI messages in the result as A2A DataParts.
Scans the result for A2UI JSON payloads and converts them into
DataParts with ``application/json+a2ui`` MIME type and A2UI metadata.
"""
if not context.state.get("a2ui_active"):
return result
if not isinstance(result, str):
return result
a2ui_messages = extract_a2ui_json_objects(result)
if not a2ui_messages:
return result
data_parts = [
part
for part in (_build_data_part(msg_data) for msg_data in a2ui_messages)
if part is not None
]
if not data_parts:
return result
return A2UIResponse(text=result, a2ui_parts=data_parts)
def _build_data_part(msg_data: dict[str, Any]) -> dict[str, Any] | None:
"""Validate a single A2UI message and wrap it as a DataPart dict."""
try:
validated = validate_a2ui_message(msg_data)
except A2UIValidationError:
logger.warning("Skipping invalid A2UI message in response", exc_info=True)
return None
return {
"kind": "data",
"data": validated.model_dump(by_alias=True, exclude_none=True),
"metadata": {
"mimeType": A2UI_MIME_TYPE,
},
}

View File

@@ -0,0 +1,59 @@
"""Validate A2UI message dicts via Pydantic models."""
from __future__ import annotations
from typing import Any
from pydantic import ValidationError
from crewai.a2a.extensions.a2ui.models import A2UIEvent, A2UIMessage
class A2UIValidationError(Exception):
"""Raised when an A2UI message fails validation."""
def __init__(self, message: str, errors: list[Any] | None = None) -> None:
super().__init__(message)
self.errors = errors or []
def validate_a2ui_message(data: dict[str, Any]) -> A2UIMessage:
"""Parse and validate an A2UI server-to-client message.
Args:
data: Raw message dict (JSON-decoded).
Returns:
Validated ``A2UIMessage`` instance.
Raises:
A2UIValidationError: If the data does not conform to the A2UI schema.
"""
try:
return A2UIMessage.model_validate(data)
except ValidationError as exc:
raise A2UIValidationError(
f"Invalid A2UI message: {exc.error_count()} validation error(s)",
errors=exc.errors(),
) from exc
def validate_a2ui_event(data: dict[str, Any]) -> A2UIEvent:
"""Parse and validate an A2UI client-to-server event.
Args:
data: Raw event dict (JSON-decoded).
Returns:
Validated ``A2UIEvent`` instance.
Raises:
A2UIValidationError: If the data does not conform to the A2UI event schema.
"""
try:
return A2UIEvent.model_validate(data)
except ValidationError as exc:
raise A2UIValidationError(
f"Invalid A2UI event: {exc.error_count()} validation error(s)",
errors=exc.errors(),
) from exc

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, Any
import uuid
from a2a.client.errors import A2AClientHTTPError
@@ -18,7 +18,7 @@ from a2a.types import (
TaskStatusUpdateEvent,
TextPart,
)
from typing_extensions import NotRequired
from typing_extensions import NotRequired, TypedDict
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.a2a_events import (

View File

@@ -7,12 +7,11 @@ from typing import (
Any,
Literal,
Protocol,
TypedDict,
runtime_checkable,
)
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
from typing_extensions import NotRequired
from typing_extensions import NotRequired, TypedDict
try:

View File

@@ -2,10 +2,11 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, TypedDict
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
from pydantic import GetCoreSchemaHandler
from pydantic_core import CoreSchema, core_schema
from typing_extensions import TypedDict
class CommonParams(NamedTuple):

View File

@@ -28,6 +28,7 @@ APPLICATION_PDF: Literal["application/pdf"] = "application/pdf"
APPLICATION_OCTET_STREAM: Literal["application/octet-stream"] = (
"application/octet-stream"
)
APPLICATION_A2UI_JSON: Literal["application/json+a2ui"] = "application/json+a2ui"
DEFAULT_CLIENT_INPUT_MODES: Final[list[Literal["text/plain", "application/json"]]] = [
TEXT_PLAIN,
@@ -311,6 +312,10 @@ def get_part_content_type(part: Part) -> str:
if root.kind == "text":
return TEXT_PLAIN
if root.kind == "data":
metadata = root.metadata or {}
mime = metadata.get("mimeType", "")
if mime == APPLICATION_A2UI_JSON:
return APPLICATION_A2UI_JSON
return APPLICATION_JSON
if root.kind == "file":
return root.file.mime_type or APPLICATION_OCTET_STREAM

View File

@@ -10,7 +10,7 @@ from functools import wraps
import json
import logging
import os
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, TypedDict, cast
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
from urllib.parse import urlparse
from a2a.server.agent_execution import RequestContext
@@ -38,6 +38,7 @@ from a2a.utils import (
from a2a.utils.errors import ServerError
from aiocache import SimpleMemoryCache, caches # type: ignore[import-untyped]
from pydantic import BaseModel
from typing_extensions import TypedDict
from crewai.a2a.utils.agent_card import _get_server_config
from crewai.a2a.utils.content_type import validate_message_parts

View File

@@ -196,6 +196,16 @@ class PlusAPI:
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.0a2"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.0a2"
"crewai[tools]==1.12.2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.12.0a2"
"crewai[tools]==1.12.2"
]
[tool.crewai]

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timezone
import logging
import uuid
import webbrowser
@@ -100,20 +101,50 @@ class FirstTimeTraceHandler:
user_context=user_context,
execution_metadata=execution_metadata,
use_ephemeral=True,
skip_context_check=True,
)
if not self.batch_manager.trace_batch_id:
self._gracefully_fail(
"Backend batch creation failed, cannot send events."
)
self._reset_batch_state()
return
self.batch_manager.backend_initialized = True
if self.batch_manager.event_buffer:
self.batch_manager._send_events_to_backend()
# Capture values before send/finalize consume them
events_count = len(self.batch_manager.event_buffer)
batch_id = self.batch_manager.trace_batch_id
# Read duration non-destructively — _finalize_backend_batch will consume it
start_time = self.batch_manager.execution_start_times.get("execution")
duration_ms = (
int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
if start_time
else 0
)
self.batch_manager.finalize_batch()
if self.batch_manager.event_buffer:
send_status = self.batch_manager._send_events_to_backend()
if send_status == 500 and self.batch_manager.trace_batch_id:
self.batch_manager._mark_batch_as_failed(
self.batch_manager.trace_batch_id,
"Error sending events to backend",
)
self._reset_batch_state()
return
self.batch_manager._finalize_backend_batch(events_count)
self.ephemeral_url = self.batch_manager.ephemeral_trace_url
if not self.ephemeral_url:
self._show_local_trace_message()
self._show_local_trace_message(events_count, duration_ms, batch_id)
self._reset_batch_state()
except Exception as e:
self._gracefully_fail(f"Backend initialization failed: {e}")
self._reset_batch_state()
def _display_ephemeral_trace_link(self) -> None:
"""Display the ephemeral trace link to the user and automatically open browser."""
@@ -185,6 +216,19 @@ To enable tracing later, do any one of these:
console.print(panel)
console.print()
def _reset_batch_state(self) -> None:
"""Reset batch manager state to allow future executions to re-initialize."""
if not self.batch_manager:
return
self.batch_manager.batch_owner_type = None
self.batch_manager.batch_owner_id = None
self.batch_manager.current_batch = None
self.batch_manager.event_buffer.clear()
self.batch_manager.trace_batch_id = None
self.batch_manager.is_current_batch_ephemeral = False
self.batch_manager.backend_initialized = False
self.batch_manager._cleanup_batch_data()
def _gracefully_fail(self, error_message: str) -> None:
"""Handle errors gracefully without disrupting user experience."""
console = Console()
@@ -192,7 +236,9 @@ To enable tracing later, do any one of these:
logger.debug(f"First-time trace error: {error_message}")
def _show_local_trace_message(self) -> None:
def _show_local_trace_message(
self, events_count: int = 0, duration_ms: int = 0, batch_id: str | None = None
) -> None:
"""Show message when traces were collected locally but couldn't be uploaded."""
if self.batch_manager is None:
return
@@ -203,9 +249,9 @@ To enable tracing later, do any one of these:
📊 Your execution traces were collected locally!
Unfortunately, we couldn't upload them to the server right now, but here's what we captured:
{len(self.batch_manager.event_buffer)} trace events
• Execution duration: {self.batch_manager.calculate_duration("execution")}ms
• Batch ID: {self.batch_manager.trace_batch_id}
{events_count} trace events
• Execution duration: {duration_ms}ms
• Batch ID: {batch_id}
✅ Tracing has been enabled for future runs!
Your preference has been saved. Future Crew/Flow executions will automatically collect traces.

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from logging import getLogger
from threading import Condition, Lock
import time
from typing import Any
import uuid
@@ -98,7 +99,7 @@ class TraceBatchManager:
self._initialize_backend_batch(
user_context, execution_metadata, use_ephemeral
)
self.backend_initialized = True
self.backend_initialized = self.trace_batch_id is not None
self._batch_ready_cv.notify_all()
return self.current_batch
@@ -108,14 +109,15 @@ class TraceBatchManager:
user_context: dict[str, str],
execution_metadata: dict[str, Any],
use_ephemeral: bool = False,
skip_context_check: bool = False,
) -> None:
"""Send batch initialization to backend"""
if not is_tracing_enabled_in_context():
return
if not skip_context_check and not is_tracing_enabled_in_context():
return None
if not self.plus_api or not self.current_batch:
return
return None
try:
payload = {
@@ -142,19 +144,53 @@ class TraceBatchManager:
payload["ephemeral_trace_id"] = self.current_batch.batch_id
payload["user_identifier"] = get_user_id()
response = (
self.plus_api.initialize_ephemeral_trace_batch(payload)
if use_ephemeral
else self.plus_api.initialize_trace_batch(payload)
)
max_retries = 1
response = None
try:
for attempt in range(max_retries + 1):
response = (
self.plus_api.initialize_ephemeral_trace_batch(payload)
if use_ephemeral
else self.plus_api.initialize_trace_batch(payload)
)
if response is not None and response.status_code < 500:
break
if attempt < max_retries:
logger.debug(
f"Trace batch init attempt {attempt + 1} failed "
f"(status={response.status_code if response else 'None'}), retrying..."
)
time.sleep(0.2)
except Exception as e:
logger.warning(
f"Error initializing trace batch: {e}. Continuing without tracing."
)
self.trace_batch_id = None
return None
if response is None:
logger.warning(
"Trace batch initialization failed gracefully. Continuing without tracing."
)
return
self.trace_batch_id = None
return None
# Fall back to ephemeral on auth failure (expired/revoked token)
if response.status_code in [401, 403] and not use_ephemeral:
logger.warning(
"Auth rejected by server, falling back to ephemeral tracing."
)
self.is_current_batch_ephemeral = True
return self._initialize_backend_batch(
user_context,
execution_metadata,
use_ephemeral=True,
skip_context_check=skip_context_check,
)
if response.status_code in [201, 200]:
self.is_current_batch_ephemeral = use_ephemeral
response_data = response.json()
self.trace_batch_id = (
response_data["trace_id"]
@@ -165,11 +201,22 @@ class TraceBatchManager:
logger.warning(
f"Trace batch initialization returned status {response.status_code}. Continuing without tracing."
)
self.trace_batch_id = None
except Exception as e:
logger.warning(
f"Error initializing trace batch: {e}. Continuing without tracing."
)
self.trace_batch_id = None
def _mark_batch_as_failed(self, trace_batch_id: str, error_message: str) -> None:
"""Mark a trace batch as failed, routing to the correct endpoint."""
if self.is_current_batch_ephemeral:
self.plus_api.mark_ephemeral_trace_batch_as_failed(
trace_batch_id, error_message
)
else:
self.plus_api.mark_trace_batch_as_failed(trace_batch_id, error_message)
def begin_event_processing(self) -> None:
"""Mark that an event handler started processing (for synchronization)."""
@@ -260,7 +307,7 @@ class TraceBatchManager:
logger.error(
"Event handler timeout - marking batch as failed due to incomplete events"
)
self.plus_api.mark_trace_batch_as_failed(
self._mark_batch_as_failed(
self.trace_batch_id,
"Timeout waiting for event handlers - events incomplete",
)
@@ -284,7 +331,7 @@ class TraceBatchManager:
events_sent_to_backend_status = self._send_events_to_backend()
self.event_buffer = original_buffer
if events_sent_to_backend_status == 500 and self.trace_batch_id:
self.plus_api.mark_trace_batch_as_failed(
self._mark_batch_as_failed(
self.trace_batch_id, "Error sending events to backend"
)
return None
@@ -364,13 +411,16 @@ class TraceBatchManager:
logger.error(
f"❌ Failed to finalize trace batch: {response.status_code} - {response.text}"
)
self.plus_api.mark_trace_batch_as_failed(
self.trace_batch_id, response.text
)
self._mark_batch_as_failed(self.trace_batch_id, response.text)
except Exception as e:
logger.error(f"❌ Error finalizing trace batch: {e}")
self.plus_api.mark_trace_batch_as_failed(self.trace_batch_id, str(e))
try:
self._mark_batch_as_failed(self.trace_batch_id, str(e))
except Exception:
logger.debug(
"Could not mark trace batch as failed (network unavailable)"
)
def _cleanup_batch_data(self) -> None:
"""Clean up batch data after successful finalization to free memory"""

View File

@@ -235,8 +235,11 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(FlowStartedEvent)
def on_flow_started(source: Any, event: FlowStartedEvent) -> None:
if not self.batch_manager.is_batch_initialized():
self._initialize_flow_batch(source, event)
# Always call _initialize_flow_batch to claim ownership.
# If batch was already initialized by a concurrent action event
# (race condition), initialize_batch() returns early but
# batch_owner_type is still correctly set to "flow".
self._initialize_flow_batch(source, event)
self._handle_trace_event("flow_started", source, event)
@event_bus.on(MethodExecutionStartedEvent)
@@ -266,7 +269,12 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffStartedEvent)
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
if not self.batch_manager.is_batch_initialized():
if self.batch_manager.batch_owner_type != "flow":
# Always call _initialize_crew_batch to claim ownership.
# If batch was already initialized by a concurrent action event
# (race condition with DefaultEnvEvent), initialize_batch() returns
# early but batch_owner_type is still correctly set to "crew".
# Skip only when a parent flow already owns the batch.
self._initialize_crew_batch(source, event)
self._handle_trace_event("crew_kickoff_started", source, event)
@@ -772,7 +780,7 @@ class TraceCollectionListener(BaseEventListener):
"crew_name": getattr(source, "name", "Unknown Crew"),
"crewai_version": get_crewai_version(),
}
self.batch_manager.initialize_batch(user_context, execution_metadata)
self._initialize_batch(user_context, execution_metadata)
self.batch_manager.begin_event_processing()
try:

View File

@@ -178,12 +178,15 @@ class HumanFeedbackRequestedEvent(FlowEvent):
output: The method output shown to the human for review.
message: The message displayed when requesting feedback.
emit: Optional list of possible outcomes for routing.
request_id: Platform-assigned identifier for this feedback request,
used for correlating the request across system boundaries.
"""
method_name: str
output: Any
message: str
emit: list[str] | None = None
request_id: str | None = None
type: str = "human_feedback_requested"
@@ -198,9 +201,12 @@ class HumanFeedbackReceivedEvent(FlowEvent):
method_name: Name of the method that received feedback.
feedback: The raw text feedback provided by the human.
outcome: The collapsed outcome string (if emit was specified).
request_id: Platform-assigned identifier for this feedback request,
used for correlating the response back to its originating request.
"""
method_name: str
feedback: str
outcome: str | None = None
request_id: str | None = None
type: str = "human_feedback_received"

View File

@@ -127,6 +127,9 @@ To update, run: uv sync --upgrade-package crewai"""
def _show_tracing_disabled_message_if_needed(self) -> None:
"""Show tracing disabled message if tracing is not enabled."""
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
from crewai.events.listeners.tracing.utils import (
has_user_declined_tracing,
is_tracing_enabled_in_context,
@@ -136,6 +139,12 @@ To update, run: uv sync --upgrade-package crewai"""
if should_suppress_tracing_messages():
return
# Don't show "disabled" message when the first-time handler will show
# the trace prompt after execution completes (avoids confusing mid-flow messages)
listener = TraceCollectionListener._instance # type: ignore[misc]
if listener and listener.first_time_handler.is_first_time:
return
if not is_tracing_enabled_in_context():
if has_user_declined_tracing():
message = """Info: Tracing is disabled.

View File

@@ -182,7 +182,7 @@ class ConsoleProvider:
console.print(message, style="yellow")
console.print()
response = input(">>> \n").strip()
response = input(">>> ").strip()
else:
response = input(f"{message} ").strip()

View File

@@ -63,6 +63,32 @@ class PendingFeedbackContext:
llm: dict[str, Any] | str | None = None
requested_at: datetime = field(default_factory=datetime.now)
@staticmethod
def _make_json_safe(value: Any) -> Any:
"""Convert a value to a JSON-serializable form.
Handles Pydantic models, dataclasses, and arbitrary objects by
progressively falling back to string representation.
"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [PendingFeedbackContext._make_json_safe(v) for v in value]
if isinstance(value, dict):
return {
k: PendingFeedbackContext._make_json_safe(v) for k, v in value.items()
}
from pydantic import BaseModel
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
import dataclasses
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return PendingFeedbackContext._make_json_safe(dataclasses.asdict(value))
return str(value)
def to_dict(self) -> dict[str, Any]:
"""Serialize context to a dictionary for persistence.
@@ -73,11 +99,11 @@ class PendingFeedbackContext:
"flow_id": self.flow_id,
"flow_class": self.flow_class,
"method_name": self.method_name,
"method_output": self.method_output,
"method_output": self._make_json_safe(self.method_output),
"message": self.message,
"emit": self.emit,
"default_outcome": self.default_outcome,
"metadata": self.metadata,
"metadata": self._make_json_safe(self.metadata),
"llm": self.llm,
"requested_at": self.requested_at.isoformat(),
}

View File

@@ -883,6 +883,9 @@ class Flow(Generic[T], metaclass=FlowMeta):
self.human_feedback_history: list[HumanFeedbackResult] = []
self.last_human_feedback: HumanFeedbackResult | None = None
self._pending_feedback_context: PendingFeedbackContext | None = None
# Per-method stash for real @human_feedback output (keyed by method name)
# Used to decouple routing outcome from method return value when emit is set
self._human_feedback_method_outputs: dict[str, Any] = {}
self.suppress_flow_events: bool = suppress_flow_events
# User input history (for self.ask())
@@ -1223,9 +1226,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
# Mark that we're resuming execution
instance._is_execution_resuming = True
# Mark the method as completed (it ran before pausing)
instance._completed_methods.add(FlowMethodName(pending_context.method_name))
return instance
@property
@@ -1380,7 +1380,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
self.human_feedback_history.append(result)
self.last_human_feedback = result
# Clear pending context after processing
self._completed_methods.add(FlowMethodName(context.method_name))
self._pending_feedback_context = None
# Clear pending feedback from persistence
@@ -1403,7 +1404,10 @@ class Flow(Generic[T], metaclass=FlowMeta):
# This allows methods to re-execute in loops (e.g., implement_changes → suggest_changes → implement_changes)
self._is_execution_resuming = False
final_result: Any = result
if emit and collapsed_outcome is None:
collapsed_outcome = default_outcome or emit[0]
result.outcome = collapsed_outcome
try:
if emit and collapsed_outcome:
self._method_outputs.append(collapsed_outcome)
@@ -1421,7 +1425,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
from crewai.flow.async_feedback.types import HumanFeedbackPending
if isinstance(e, HumanFeedbackPending):
# Auto-save pending feedback (create default persistence if needed)
self._pending_feedback_context = e.context
if self._persistence is None:
from crewai.flow.persistence import SQLiteFlowPersistence
@@ -1455,6 +1460,8 @@ class Flow(Generic[T], metaclass=FlowMeta):
return e
raise
final_result = self._method_outputs[-1] if self._method_outputs else result
# Emit flow finished
crewai_event_bus.emit(
self,
@@ -2286,6 +2293,17 @@ class Flow(Generic[T], metaclass=FlowMeta):
result = await result
self._method_outputs.append(result)
# For @human_feedback methods with emit, the result is the collapsed outcome
# (e.g., "approved") used for routing. But we want the actual method output
# to be the stored result (for final flow output). Replace the last entry
# if a stashed output exists. Dict-based stash is concurrency-safe and
# handles None return values (presence in dict = stashed, not value).
if method_name in self._human_feedback_method_outputs:
self._method_outputs[-1] = self._human_feedback_method_outputs.pop(
method_name
)
self._method_execution_counts[method_name] = (
self._method_execution_counts.get(method_name, 0) + 1
)
@@ -2314,7 +2332,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
if isinstance(e, HumanFeedbackPending):
e.context.method_name = method_name
# Auto-save pending feedback (create default persistence if needed)
if self._persistence is None:
from crewai.flow.persistence import SQLiteFlowPersistence
@@ -3133,10 +3150,16 @@ class Flow(Generic[T], metaclass=FlowMeta):
if outcome.lower() == response_clean.lower():
return outcome
# Partial match
# Partial match (longest wins, first on length ties)
response_lower = response_clean.lower()
best_outcome: str | None = None
best_len = -1
for outcome in outcomes:
if outcome.lower() in response_clean.lower():
return outcome
if outcome.lower() in response_lower and len(outcome) > best_len:
best_outcome = outcome
best_len = len(outcome)
if best_outcome is not None:
return best_outcome
# Fallback to first outcome
logger.warning(

View File

@@ -116,10 +116,11 @@ def _deserialize_llm_from_context(
return LLM(model=llm_data)
if isinstance(llm_data, dict):
model = llm_data.pop("model", None)
data = dict(llm_data)
model = data.pop("model", None)
if not model:
return None
return LLM(model=model, **llm_data)
return LLM(model=model, **data)
return None
@@ -450,12 +451,12 @@ def human_feedback(
# -- Core feedback helpers ------------------------------------
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
"""Request feedback using provider or default console."""
def _build_feedback_context(
flow_instance: Flow[Any], method_output: Any
) -> tuple[Any, Any]:
"""Build the PendingFeedbackContext and resolve the effective provider."""
from crewai.flow.async_feedback.types import PendingFeedbackContext
# Build context for provider
# Use flow_id property which handles both dict and BaseModel states
context = PendingFeedbackContext(
flow_id=flow_instance.flow_id or "unknown",
flow_class=f"{flow_instance.__class__.__module__}.{flow_instance.__class__.__name__}",
@@ -468,15 +469,53 @@ def human_feedback(
llm=llm if isinstance(llm, str) else _serialize_llm_for_context(llm),
)
# Determine effective provider:
effective_provider = provider
if effective_provider is None:
from crewai.flow.flow_config import flow_config
effective_provider = flow_config.hitl_provider
return context, effective_provider
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
"""Request feedback using provider or default console (sync)."""
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
return effective_provider.request_feedback(context, flow_instance)
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
raise TypeError(
f"Provider {type(effective_provider).__name__}.request_feedback() "
"returned a coroutine in a sync flow method. Use an async flow "
"method or a synchronous provider."
)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
async def _request_feedback_async(
flow_instance: Flow[Any], method_output: Any
) -> str:
"""Request feedback, awaiting the provider if it returns a coroutine."""
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
return str(await feedback_result)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
@@ -524,10 +563,11 @@ def human_feedback(
flow_instance.human_feedback_history.append(result)
flow_instance.last_human_feedback = result
# Return based on mode
if emit:
# Return outcome for routing
return collapsed_outcome # type: ignore[return-value]
if collapsed_outcome is None:
collapsed_outcome = default_outcome or emit[0]
result.outcome = collapsed_outcome
return collapsed_outcome
return result
if asyncio.iscoroutinefunction(func):
@@ -540,7 +580,7 @@ def human_feedback(
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
raw_feedback = _request_feedback(self, method_output)
raw_feedback = await _request_feedback_async(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
# Distill: extract lessons from output + feedback, store in memory
@@ -551,6 +591,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper: Any = async_wrapper
@@ -575,6 +622,13 @@ def human_feedback(
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set
# (result is the collapsed outcome string for routing, but we want to
# preserve the actual method output as the flow's final result)
# Uses per-method dict for concurrency safety and to handle None returns
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper = sync_wrapper

View File

@@ -483,8 +483,8 @@ class LLM(BaseLLM):
for prefix in ["gpt-", "gpt-35-", "o1", "o3", "o4", "azure-"]
)
# OpenAI-compatible providers - accept any model name since these
# providers host many different models with varied naming conventions
# OpenAI-compatible providers - most accept any model name, but some
# (DeepSeek, Dashscope) restrict to their own model prefixes
if provider == "deepseek":
return model_lower.startswith("deepseek")

View File

@@ -239,7 +239,8 @@ class OpenAICompatibleCompletion(OpenAICompletion):
if base_url:
resolved = base_url
elif config.base_url_env:
resolved = os.getenv(config.base_url_env, config.base_url)
env_value = os.getenv(config.base_url_env)
resolved = env_value if env_value else config.base_url
else:
resolved = config.base_url
@@ -274,9 +275,11 @@ class OpenAICompatibleCompletion(OpenAICompletion):
def supports_function_calling(self) -> bool:
"""Check if the provider supports function calling.
All modern OpenAI-compatible providers support function calling.
Delegates to the parent OpenAI implementation which handles
edge cases like o1 models (which may be routed through
OpenRouter or other compatible providers).
Returns:
True, as all supported providers have function calling support.
Whether the model supports function calling.
"""
return True
return super().supports_function_calling()

View File

@@ -0,0 +1,319 @@
"""Cross-validate A2UI Pydantic models against vendored JSON schemas.
Ensures the two validation sources stay in sync: representative payloads
must be accepted or rejected consistently by both the Pydantic models and
the JSON schemas.
"""
from __future__ import annotations
from typing import Any
import jsonschema
import pytest
from crewai.a2a.extensions.a2ui import catalog
from crewai.a2a.extensions.a2ui.models import A2UIEvent, A2UIMessage
from crewai.a2a.extensions.a2ui.schema import load_schema
SERVER_SCHEMA = load_schema("server_to_client")
CLIENT_SCHEMA = load_schema("client_to_server")
CATALOG_SCHEMA = load_schema("standard_catalog_definition")
def _json_schema_valid(schema: dict[str, Any], instance: dict[str, Any]) -> bool:
"""Return True if *instance* validates against *schema*."""
try:
jsonschema.validate(instance, schema)
return True
except jsonschema.ValidationError:
return False
def _pydantic_valid_message(data: dict[str, Any]) -> bool:
"""Return True if *data* validates as an A2UIMessage."""
try:
A2UIMessage.model_validate(data)
return True
except Exception:
return False
def _pydantic_valid_event(data: dict[str, Any]) -> bool:
"""Return True if *data* validates as an A2UIEvent."""
try:
A2UIEvent.model_validate(data)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Valid server-to-client payloads
# ---------------------------------------------------------------------------
VALID_SERVER_MESSAGES: list[dict[str, Any]] = [
{
"beginRendering": {
"surfaceId": "s1",
"root": "root-col",
},
},
{
"beginRendering": {
"surfaceId": "s2",
"root": "root-col",
"catalogId": "standard (v0.8)",
"styles": {"primaryColor": "#FF0000", "font": "Roboto"},
},
},
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{
"id": "title",
"component": {
"Text": {"text": {"literalString": "Hello"}},
},
},
],
},
},
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{
"id": "weighted",
"weight": 2.0,
"component": {
"Column": {
"children": {"explicitList": ["a", "b"]},
},
},
},
],
},
},
{
"dataModelUpdate": {
"surfaceId": "s1",
"contents": [
{"key": "name", "valueString": "Alice"},
{"key": "score", "valueNumber": 42},
{"key": "active", "valueBoolean": True},
],
},
},
{
"dataModelUpdate": {
"surfaceId": "s1",
"path": "/user",
"contents": [
{
"key": "prefs",
"valueMap": [
{"key": "theme", "valueString": "dark"},
],
},
],
},
},
{
"deleteSurface": {"surfaceId": "s1"},
},
]
# ---------------------------------------------------------------------------
# Invalid server-to-client payloads
# ---------------------------------------------------------------------------
INVALID_SERVER_MESSAGES: list[dict[str, Any]] = [
{},
{"beginRendering": {"surfaceId": "s1"}},
{"surfaceUpdate": {"surfaceId": "s1", "components": []}},
{
"beginRendering": {"surfaceId": "s1", "root": "r"},
"deleteSurface": {"surfaceId": "s1"},
},
{"unknownType": {"surfaceId": "s1"}},
]
# ---------------------------------------------------------------------------
# Valid client-to-server payloads
# ---------------------------------------------------------------------------
VALID_CLIENT_EVENTS: list[dict[str, Any]] = [
{
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
"context": {},
},
},
{
"userAction": {
"name": "submit",
"surfaceId": "s1",
"sourceComponentId": "btn-2",
"timestamp": "2026-03-12T10:00:00Z",
"context": {"field": "value"},
},
},
{
"error": {"message": "render failed", "code": 500},
},
]
# ---------------------------------------------------------------------------
# Invalid client-to-server payloads
# ---------------------------------------------------------------------------
INVALID_CLIENT_EVENTS: list[dict[str, Any]] = [
{},
{"userAction": {"name": "click"}},
{
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
"context": {},
},
"error": {"message": "oops"},
},
]
# ---------------------------------------------------------------------------
# Catalog component payloads (validated structurally)
# ---------------------------------------------------------------------------
VALID_COMPONENTS: dict[str, dict[str, Any]] = {
"Text": {"text": {"literalString": "hello"}, "usageHint": "h1"},
"Image": {"url": {"path": "/img/url"}, "fit": "cover", "usageHint": "avatar"},
"Icon": {"name": {"literalString": "home"}},
"Video": {"url": {"literalString": "https://example.com/video.mp4"}},
"AudioPlayer": {"url": {"literalString": "https://example.com/audio.mp3"}},
"Row": {"children": {"explicitList": ["a", "b"]}, "distribution": "center"},
"Column": {"children": {"template": {"componentId": "c1", "dataBinding": "/list"}}},
"List": {"children": {"explicitList": ["x"]}, "direction": "horizontal"},
"Card": {"child": "inner"},
"Tabs": {"tabItems": [{"title": {"literalString": "Tab 1"}, "child": "content"}]},
"Divider": {"axis": "horizontal"},
"Modal": {"entryPointChild": "trigger", "contentChild": "body"},
"Button": {"child": "label", "action": {"name": "go"}},
"CheckBox": {"label": {"literalString": "Accept"}, "value": {"literalBoolean": False}},
"TextField": {"label": {"literalString": "Name"}},
"DateTimeInput": {"value": {"path": "/date"}},
"MultipleChoice": {
"selections": {"literalArray": ["a"]},
"options": [{"label": {"literalString": "A"}, "value": "a"}],
},
"Slider": {"value": {"literalNumber": 50}, "minValue": 0, "maxValue": 100},
}
class TestServerToClientConformance:
"""Pydantic models and JSON schema must agree on server-to-client messages."""
@pytest.mark.parametrize("payload", VALID_SERVER_MESSAGES)
def test_valid_accepted_by_both(self, payload: dict[str, Any]) -> None:
assert _json_schema_valid(SERVER_SCHEMA, payload), (
f"JSON schema rejected valid payload: {payload}"
)
assert _pydantic_valid_message(payload), (
f"Pydantic rejected valid payload: {payload}"
)
@pytest.mark.parametrize("payload", INVALID_SERVER_MESSAGES)
def test_invalid_rejected_by_pydantic(self, payload: dict[str, Any]) -> None:
assert not _pydantic_valid_message(payload), (
f"Pydantic accepted invalid payload: {payload}"
)
class TestClientToServerConformance:
"""Pydantic models and JSON schema must agree on client-to-server events."""
@pytest.mark.parametrize("payload", VALID_CLIENT_EVENTS)
def test_valid_accepted_by_both(self, payload: dict[str, Any]) -> None:
assert _json_schema_valid(CLIENT_SCHEMA, payload), (
f"JSON schema rejected valid payload: {payload}"
)
assert _pydantic_valid_event(payload), (
f"Pydantic rejected valid payload: {payload}"
)
@pytest.mark.parametrize("payload", INVALID_CLIENT_EVENTS)
def test_invalid_rejected_by_pydantic(self, payload: dict[str, Any]) -> None:
assert not _pydantic_valid_event(payload), (
f"Pydantic accepted invalid payload: {payload}"
)
class TestCatalogConformance:
"""Catalog component schemas and Pydantic models must define the same components."""
def test_catalog_component_names_match(self) -> None:
from crewai.a2a.extensions.a2ui.catalog import STANDARD_CATALOG_COMPONENTS
schema_components = set(CATALOG_SCHEMA["components"].keys())
assert schema_components == STANDARD_CATALOG_COMPONENTS
@pytest.mark.parametrize(
"name,props",
list(VALID_COMPONENTS.items()),
)
def test_valid_component_accepted_by_catalog_schema(
self, name: str, props: dict[str, Any]
) -> None:
component_schema = CATALOG_SCHEMA["components"][name]
assert _json_schema_valid(component_schema, props), (
f"Catalog schema rejected valid {name}: {props}"
)
@pytest.mark.parametrize(
"name,props",
list(VALID_COMPONENTS.items()),
)
def test_valid_component_accepted_by_pydantic(
self, name: str, props: dict[str, Any]
) -> None:
model_cls = getattr(catalog, name)
try:
model_cls.model_validate(props)
except Exception as exc:
pytest.fail(f"Pydantic {name} rejected valid props: {exc}")
def test_catalog_required_fields_match(self) -> None:
"""Required fields in the JSON schema match non-optional Pydantic fields."""
for comp_name, comp_schema in CATALOG_SCHEMA["components"].items():
schema_required = set(comp_schema.get("required", []))
model_cls = getattr(catalog, comp_name)
pydantic_required = {
info.alias or field_name
for field_name, info in model_cls.model_fields.items()
if info.is_required()
}
assert schema_required == pydantic_required, (
f"{comp_name}: schema requires {schema_required}, "
f"Pydantic requires {pydantic_required}"
)
def test_catalog_fields_match(self) -> None:
"""Field names in JSON schema match Pydantic model aliases."""
for comp_name, comp_schema in CATALOG_SCHEMA["components"].items():
schema_fields = set(comp_schema.get("properties", {}).keys())
model_cls = getattr(catalog, comp_name)
pydantic_fields = {
info.alias or field_name
for field_name, info in model_cls.model_fields.items()
}
assert schema_fields == pydantic_fields, (
f"{comp_name}: schema has {schema_fields}, "
f"Pydantic has {pydantic_fields}"
)

View File

@@ -1,7 +1,7 @@
"""Tests for OpenAI-compatible providers."""
import os
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@@ -133,7 +133,7 @@ class TestOpenAICompatibleCompletion:
with pytest.raises(ValueError, match="API key required"):
OpenAICompatibleCompletion(model="deepseek-chat", provider="deepseek")
finally:
if original:
if original is not None:
os.environ[env_key] = original
def test_api_key_from_env(self):

View File

@@ -246,7 +246,7 @@ class TestHumanFeedbackExecution:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_with_default_outcome(self, mock_print, mock_input):
"""Test empty feedback uses default_outcome."""
"""Test empty feedback uses default_outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -264,14 +264,16 @@ class TestHumanFeedbackExecution:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "needs_work"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "needs_work"
@patch("builtins.input", return_value="Approved!")
@patch("builtins.print")
def test_feedback_collapsing(self, mock_print, mock_input):
"""Test that feedback is collapsed to an outcome."""
"""Test that feedback is collapsed to an outcome for routing, but flow returns method output."""
class TestFlow(Flow):
@start()
@@ -291,8 +293,10 @@ class TestHumanFeedbackExecution:
):
result = flow.kickoff()
assert result == "approved"
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "Content"
assert flow.last_human_feedback is not None
# But the outcome is still correctly set for routing purposes
assert flow.last_human_feedback.outcome == "approved"
@@ -591,3 +595,162 @@ class TestHumanFeedbackLearn:
assert config.learn is True
# llm defaults to "gpt-4o-mini" at the function level
assert config.llm == "gpt-4o-mini"
class TestHumanFeedbackFinalOutputPreservation:
"""Tests for preserving method return value as flow's final output when @human_feedback with emit is terminal.
This addresses the bug where the flow's final output was the collapsed outcome string (e.g., 'approved')
instead of the method's actual return value when a @human_feedback method with emit is the final method.
"""
@patch("builtins.input", return_value="Looks good!")
@patch("builtins.print")
def test_final_output_is_method_return_not_collapsed_outcome(
self, mock_print, mock_input
):
"""When @human_feedback with emit is the final method, flow output is the method's return value."""
class FinalHumanFeedbackFlow(Flow):
@start()
@human_feedback(
message="Review this content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate_and_review(self):
# This dict should be the final output, NOT the string 'approved'
return {"title": "My Article", "content": "Article content here", "status": "ready"}
flow = FinalHumanFeedbackFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="Looks great, approved!"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The final output should be the actual method return value, not the collapsed outcome
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"title": "My Article", "content": "Article content here", "status": "ready"}
# But the outcome should still be tracked in last_human_feedback
assert flow.last_human_feedback is not None
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="approved")
@patch("builtins.print")
def test_routing_still_works_with_downstream_listener(self, mock_print, mock_input):
"""When @human_feedback has a downstream listener, routing still triggers the listener."""
publish_called = []
class RoutingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return {"content": "original content"}
@listen("approved")
def publish(self):
publish_called.append(True)
return {"published": True, "timestamp": "2024-01-01"}
flow = RoutingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="LGTM"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# The downstream listener should have been triggered
assert len(publish_called) == 1, "publish() should have been called"
# The final output should be from the listener, not the human_feedback method
assert result == {"published": True, "timestamp": "2024-01-01"}
@patch("builtins.input", return_value="")
@patch("builtins.print")
@pytest.mark.asyncio
async def test_async_human_feedback_final_output_preserved(self, mock_print, mock_input):
"""Async @human_feedback methods also preserve the real return value."""
class AsyncFinalFlow(Flow):
@start()
@human_feedback(
message="Review async content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
default_outcome="approved",
)
async def async_generate(self):
return {"async_data": "value", "computed": 42}
flow = AsyncFinalFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
):
result = await flow.kickoff_async()
# The final output should be the dict, not "approved"
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}: {result}"
assert result == {"async_data": "value", "computed": 42}
assert flow.last_human_feedback.outcome == "approved"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")
def test_method_outputs_contains_real_output(self, mock_print, mock_input):
"""The _method_outputs list should contain the real method output, not the collapsed outcome."""
class OutputTrackingFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def generate(self):
return {"data": "real output"}
flow = OutputTrackingFlow()
with (
patch.object(flow, "_request_human_feedback", return_value="approved"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
flow.kickoff()
# _method_outputs should contain the real output
assert len(flow._method_outputs) == 1
assert flow._method_outputs[0] == {"data": "real output"}
@patch("builtins.input", return_value="looks good")
@patch("builtins.print")
def test_none_return_value_is_preserved(self, mock_print, mock_input):
"""A method returning None should preserve None as flow output, not the outcome string."""
class NoneReturnFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def process(self):
# Method does work but returns None (implicit)
pass
flow = NoneReturnFlow()
with (
patch.object(flow, "_request_human_feedback", return_value=""),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
# Final output should be None (the method's real return), not "approved"
assert result is None, f"Expected None, got {result!r}"
assert flow.last_human_feedback.outcome == "approved"

View File

@@ -708,7 +708,7 @@ class TestEdgeCases:
@patch("builtins.input", return_value="")
@patch("builtins.print")
def test_empty_feedback_first_outcome_fallback(self, mock_print, mock_input):
"""Test that empty feedback without default uses first outcome."""
"""Test that empty feedback without default uses first outcome for routing, but returns method output."""
class FallbackFlow(Flow):
@start()
@@ -726,12 +726,15 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=""):
result = flow.kickoff()
assert result == "first" # Falls back to first outcome
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is still set to first for routing purposes
assert flow.last_human_feedback.outcome == "first"
@patch("builtins.input", return_value="whitespace only ")
@patch("builtins.print")
def test_whitespace_only_feedback_treated_as_empty(self, mock_print, mock_input):
"""Test that whitespace-only feedback is treated as empty."""
"""Test that whitespace-only feedback is treated as empty for routing, but returns method output."""
class WhitespaceFlow(Flow):
@start()
@@ -749,7 +752,10 @@ class TestEdgeCases:
with patch.object(flow, "_request_human_feedback", return_value=" "):
result = flow.kickoff()
assert result == "reject" # Uses default because feedback is empty after strip
# Flow result is the method's return value, NOT the collapsed outcome
assert result == "content"
# But outcome is set to default because feedback is empty after strip
assert flow.last_human_feedback.outcome == "reject"
@patch("builtins.input", return_value="feedback")
@patch("builtins.print")

View File

@@ -7,6 +7,7 @@ from crewai.events.listeners.tracing.first_time_trace_handler import (
FirstTimeTraceHandler,
)
from crewai.events.listeners.tracing.trace_batch_manager import (
TraceBatch,
TraceBatchManager,
)
from crewai.events.listeners.tracing.trace_listener import (
@@ -657,6 +658,16 @@ class TestTraceListenerSetup:
trace_listener.first_time_handler.collected_events = True
mock_batch_response = MagicMock()
mock_batch_response.status_code = 201
mock_batch_response.json.return_value = {
"trace_id": "mock-trace-id",
"ephemeral_trace_id": "mock-ephemeral-trace-id",
"access_code": "TRACE-mock",
}
mock_events_response = MagicMock()
mock_events_response.status_code = 200
with (
patch.object(
trace_listener.first_time_handler,
@@ -666,6 +677,40 @@ class TestTraceListenerSetup:
patch.object(
trace_listener.first_time_handler, "_display_ephemeral_trace_link"
) as mock_display_link,
patch.object(
trace_listener.batch_manager.plus_api,
"initialize_trace_batch",
return_value=mock_batch_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"initialize_ephemeral_trace_batch",
return_value=mock_batch_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"send_trace_events",
return_value=mock_events_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"send_ephemeral_trace_events",
return_value=mock_events_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"finalize_trace_batch",
return_value=mock_events_response,
),
patch.object(
trace_listener.batch_manager.plus_api,
"finalize_ephemeral_trace_batch",
return_value=mock_events_response,
),
patch.object(
trace_listener.batch_manager,
"_cleanup_batch_data",
),
):
crew.kickoff()
wait_for_event_handlers()
@@ -918,3 +963,676 @@ class TestTraceListenerSetup:
mock_init.assert_called_once()
payload = mock_init.call_args[0][0]
assert "user_identifier" not in payload
class TestTraceBatchIdClearedOnFailure:
"""Tests: trace_batch_id is cleared when _initialize_backend_batch fails."""
def _make_batch_manager(self):
"""Create a TraceBatchManager with a pre-set trace_batch_id (simulating first-time user)."""
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.current_batch = TraceBatch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew", "crew_name": "test"},
)
bm.trace_batch_id = bm.current_batch.batch_id # simulate line 96
bm.is_current_batch_ephemeral = True
return bm
def test_trace_batch_id_cleared_on_exception(self):
"""trace_batch_id must be None when the API call raises an exception."""
bm = self._make_batch_manager()
assert bm.trace_batch_id is not None
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
side_effect=ConnectionError("network down"),
),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id is None
def test_trace_batch_id_set_on_success(self):
"""trace_batch_id must be set from the server response on success."""
bm = self._make_batch_manager()
server_id = "server-ephemeral-trace-id-999"
mock_response = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=mock_response,
),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id == server_id
def test_send_events_skipped_when_trace_batch_id_none(self):
"""_send_events_to_backend must return early when trace_batch_id is None."""
bm = self._make_batch_manager()
bm.trace_batch_id = None
bm.event_buffer = [MagicMock()] # has events
with patch.object(
bm.plus_api, "send_ephemeral_trace_events"
) as mock_send:
result = bm._send_events_to_backend()
assert result == 500
mock_send.assert_not_called()
class TestInitializeBackendBatchRetry:
"""Tests for retry logic in _initialize_backend_batch."""
def _make_batch_manager(self):
"""Create a TraceBatchManager with a pre-set trace_batch_id."""
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.current_batch = TraceBatch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew", "crew_name": "test"},
)
bm.trace_batch_id = bm.current_batch.batch_id
bm.is_current_batch_ephemeral = True
return bm
def test_retries_on_none_response_then_succeeds(self):
"""Retries when API returns None, succeeds on second attempt."""
bm = self._make_batch_manager()
server_id = "server-id-after-retry"
success_response = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
side_effect=[None, success_response],
) as mock_init,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep") as mock_sleep,
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id == server_id
assert mock_init.call_count == 2
mock_sleep.assert_called_once_with(0.2)
def test_retries_on_5xx_then_succeeds(self):
"""Retries on 500 server error, succeeds on second attempt."""
bm = self._make_batch_manager()
server_id = "server-id-after-5xx"
error_response = MagicMock(status_code=500, text="Internal Server Error")
success_response = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
side_effect=[error_response, success_response],
) as mock_init,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id == server_id
assert mock_init.call_count == 2
def test_no_retry_on_exception(self):
"""Exceptions (e.g. timeout, connection error) abort immediately without retry."""
bm = self._make_batch_manager()
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
side_effect=ConnectionError("network down"),
) as mock_init,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep") as mock_sleep,
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id is None
assert mock_init.call_count == 1
mock_sleep.assert_not_called()
def test_no_retry_on_4xx(self):
"""Does NOT retry on 422 — client error is not transient."""
bm = self._make_batch_manager()
error_response = MagicMock(status_code=422, text="Unprocessable Entity")
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=error_response,
) as mock_init,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep") as mock_sleep,
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id is None
assert mock_init.call_count == 1
mock_sleep.assert_not_called()
def test_exhausts_retries_then_clears_batch_id(self):
"""After all retries fail, trace_batch_id is None."""
bm = self._make_batch_manager()
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=None,
) as mock_init,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id is None
assert mock_init.call_count == 2 # initial + 1 retry
class TestFirstTimeHandlerBackendInitGuard:
"""Tests: backend_initialized gated on actual batch creation success."""
def _make_handler_with_manager(self):
"""Create a FirstTimeTraceHandler wired to a TraceBatchManager."""
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.current_batch = TraceBatch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew", "crew_name": "test"},
)
bm.trace_batch_id = bm.current_batch.batch_id
bm.is_current_batch_ephemeral = True
handler = FirstTimeTraceHandler()
handler.is_first_time = True
handler.collected_events = True
handler.batch_manager = bm
return handler, bm
def test_backend_initialized_true_on_success(self):
"""Events are sent when batch creation succeeds, then state is cleaned up."""
handler, bm = self._make_handler_with_manager()
server_id = "server-id-abc"
mock_init_response = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
mock_send_response = MagicMock(status_code=200)
trace_batch_id_during_send = None
def capture_send(*args, **kwargs):
nonlocal trace_batch_id_during_send
trace_batch_id_during_send = bm.trace_batch_id
return mock_send_response
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=mock_init_response,
),
patch.object(
bm.plus_api,
"send_ephemeral_trace_events",
side_effect=capture_send,
),
patch.object(bm, "_finalize_backend_batch"),
):
bm.event_buffer = [MagicMock(to_dict=MagicMock(return_value={}))]
handler._initialize_backend_and_send_events()
# trace_batch_id was set correctly during send
assert trace_batch_id_during_send == server_id
# State cleaned up after completion (singleton reuse)
assert bm.backend_initialized is False
assert bm.trace_batch_id is None
assert bm.current_batch is None
def test_backend_initialized_false_on_failure(self):
"""backend_initialized stays False and events are NOT sent when batch creation fails."""
handler, bm = self._make_handler_with_manager()
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=None, # server call fails
),
patch.object(bm, "_send_events_to_backend") as mock_send,
patch.object(bm, "_finalize_backend_batch") as mock_finalize,
patch.object(handler, "_gracefully_fail") as mock_fail,
):
bm.event_buffer = [MagicMock()]
handler._initialize_backend_and_send_events()
assert bm.backend_initialized is False
assert bm.trace_batch_id is None
mock_send.assert_not_called()
mock_finalize.assert_not_called()
mock_fail.assert_called_once()
def test_backend_initialized_false_on_non_2xx(self):
"""backend_initialized stays False when server returns non-2xx."""
handler, bm = self._make_handler_with_manager()
mock_response = MagicMock(status_code=500, text="Internal Server Error")
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=mock_response,
),
patch.object(bm, "_send_events_to_backend") as mock_send,
patch.object(bm, "_finalize_backend_batch") as mock_finalize,
patch.object(handler, "_gracefully_fail") as mock_fail,
):
bm.event_buffer = [MagicMock()]
handler._initialize_backend_and_send_events()
assert bm.backend_initialized is False
assert bm.trace_batch_id is None
mock_send.assert_not_called()
mock_finalize.assert_not_called()
mock_fail.assert_called_once()
class TestFirstTimeHandlerAlwaysEphemeral:
"""Tests that first-time handler always uses ephemeral with skip_context_check."""
def _make_handler_with_manager(self):
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.current_batch = TraceBatch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew", "crew_name": "test"},
)
bm.trace_batch_id = bm.current_batch.batch_id
bm.is_current_batch_ephemeral = True
handler = FirstTimeTraceHandler()
handler.is_first_time = True
handler.collected_events = True
handler.batch_manager = bm
return handler, bm
def test_deferred_init_uses_ephemeral_and_skip_context_check(self):
"""Deferred backend init always uses ephemeral=True and skip_context_check=True."""
handler, bm = self._make_handler_with_manager()
with (
patch.object(bm, "_initialize_backend_batch") as mock_init,
patch.object(bm, "_send_events_to_backend"),
patch.object(bm, "_finalize_backend_batch"),
):
mock_init.side_effect = lambda **kwargs: None
bm.event_buffer = [MagicMock()]
handler._initialize_backend_and_send_events()
mock_init.assert_called_once()
assert mock_init.call_args.kwargs["use_ephemeral"] is True
assert mock_init.call_args.kwargs["skip_context_check"] is True
class TestAuthFailbackToEphemeral:
"""Tests for ephemeral fallback when server rejects auth (401/403)."""
def _make_batch_manager(self):
"""Create a TraceBatchManager with a pre-set trace_batch_id."""
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.current_batch = TraceBatch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew", "crew_name": "test"},
)
bm.trace_batch_id = bm.current_batch.batch_id
bm.is_current_batch_ephemeral = False # authenticated path
return bm
def test_401_non_ephemeral_falls_back_to_ephemeral(self):
"""A 401 on the non-ephemeral endpoint should retry as ephemeral."""
bm = self._make_batch_manager()
server_id = "ephemeral-fallback-id"
auth_rejected = MagicMock(status_code=401, text="Bad credentials")
ephemeral_success = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_trace_batch",
return_value=auth_rejected,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=ephemeral_success,
) as mock_ephemeral,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=False,
)
assert bm.trace_batch_id == server_id
assert bm.is_current_batch_ephemeral is True
mock_ephemeral.assert_called_once()
def test_403_non_ephemeral_falls_back_to_ephemeral(self):
"""A 403 on the non-ephemeral endpoint should also fall back."""
bm = self._make_batch_manager()
server_id = "ephemeral-fallback-403"
forbidden = MagicMock(status_code=403, text="Forbidden")
ephemeral_success = MagicMock(
status_code=201,
json=MagicMock(return_value={"ephemeral_trace_id": server_id}),
)
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_trace_batch",
return_value=forbidden,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=ephemeral_success,
),
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=False,
)
assert bm.trace_batch_id == server_id
assert bm.is_current_batch_ephemeral is True
def test_401_on_ephemeral_does_not_recurse(self):
"""A 401 on the ephemeral endpoint should NOT try to fall back again."""
bm = self._make_batch_manager()
bm.is_current_batch_ephemeral = True
auth_rejected = MagicMock(status_code=401, text="Bad credentials")
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=auth_rejected,
) as mock_ephemeral,
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=True,
)
assert bm.trace_batch_id is None
# Called only once — no recursive fallback
mock_ephemeral.assert_called()
def test_401_fallback_ephemeral_also_fails(self):
"""If ephemeral fallback also fails, trace_batch_id is cleared."""
bm = self._make_batch_manager()
auth_rejected = MagicMock(status_code=401, text="Bad credentials")
ephemeral_fail = MagicMock(status_code=422, text="Validation failed")
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch.object(
bm.plus_api,
"initialize_trace_batch",
return_value=auth_rejected,
),
patch.object(
bm.plus_api,
"initialize_ephemeral_trace_batch",
return_value=ephemeral_fail,
),
patch("crewai.events.listeners.tracing.trace_batch_manager.time.sleep"),
):
bm._initialize_backend_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
use_ephemeral=False,
)
assert bm.trace_batch_id is None
class TestMarkBatchAsFailedRouting:
"""Tests: _mark_batch_as_failed routes to the correct endpoint."""
def _make_batch_manager(self, ephemeral: bool = False):
with patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
):
bm = TraceBatchManager()
bm.is_current_batch_ephemeral = ephemeral
return bm
def test_routes_to_ephemeral_endpoint_when_ephemeral(self):
"""Ephemeral batches must use mark_ephemeral_trace_batch_as_failed."""
bm = self._make_batch_manager(ephemeral=True)
with patch.object(
bm.plus_api, "mark_ephemeral_trace_batch_as_failed"
) as mock_ephemeral, patch.object(
bm.plus_api, "mark_trace_batch_as_failed"
) as mock_non_ephemeral:
bm._mark_batch_as_failed("batch-123", "some error")
mock_ephemeral.assert_called_once_with("batch-123", "some error")
mock_non_ephemeral.assert_not_called()
def test_routes_to_non_ephemeral_endpoint_when_not_ephemeral(self):
"""Non-ephemeral batches must use mark_trace_batch_as_failed."""
bm = self._make_batch_manager(ephemeral=False)
with patch.object(
bm.plus_api, "mark_ephemeral_trace_batch_as_failed"
) as mock_ephemeral, patch.object(
bm.plus_api, "mark_trace_batch_as_failed"
) as mock_non_ephemeral:
bm._mark_batch_as_failed("batch-456", "another error")
mock_non_ephemeral.assert_called_once_with("batch-456", "another error")
mock_ephemeral.assert_not_called()
class TestBackendInitializedGatedOnSuccess:
"""Tests: backend_initialized reflects actual init success on non-first-time path."""
def test_backend_initialized_true_on_success(self):
"""backend_initialized is True when _initialize_backend_batch succeeds."""
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.should_auto_collect_first_time_traces",
return_value=False,
),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
),
):
bm = TraceBatchManager()
mock_response = MagicMock(
status_code=201,
json=MagicMock(return_value={"trace_id": "server-id"}),
)
with patch.object(
bm.plus_api, "initialize_trace_batch", return_value=mock_response
):
bm.initialize_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
)
assert bm.backend_initialized is True
assert bm.trace_batch_id == "server-id"
def test_backend_initialized_false_on_failure(self):
"""backend_initialized is False when _initialize_backend_batch fails."""
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.should_auto_collect_first_time_traces",
return_value=False,
),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.get_auth_token",
return_value="mock_token",
),
):
bm = TraceBatchManager()
with patch.object(
bm.plus_api, "initialize_trace_batch", return_value=None
):
bm.initialize_batch(
user_context={"privacy_level": "standard"},
execution_metadata={"execution_type": "crew"},
)
assert bm.backend_initialized is False
assert bm.trace_batch_id is None

View File

@@ -8,18 +8,22 @@ Installed automatically via the workspace (`uv sync`). Requires:
- [GitHub CLI](https://cli.github.com/) (`gh`) — authenticated
- `OPENAI_API_KEY` env var — for release note generation and translation
- `ENTERPRISE_REPO` env var — GitHub repo for enterprise releases
- `ENTERPRISE_VERSION_DIRS` env var — comma-separated directories to bump in the enterprise repo
- `ENTERPRISE_CREWAI_DEP_PATH` env var — path to the pyproject.toml with the `crewai[tools]` pin in the enterprise repo
## Commands
### `devtools release <version>`
Full end-to-end release. Bumps versions, creates PRs, tags, and publishes a GitHub release.
Full end-to-end release. Bumps versions, creates PRs, tags, publishes a GitHub release, and releases the enterprise repo.
```
devtools release 1.10.3
devtools release 1.10.3a1 # pre-release
devtools release 1.10.3 --no-edit # skip editing release notes
devtools release 1.10.3 --dry-run # preview without changes
devtools release 1.10.3a1 # pre-release
devtools release 1.10.3 --no-edit # skip editing release notes
devtools release 1.10.3 --dry-run # preview without changes
devtools release 1.10.3 --skip-enterprise # skip enterprise release phase
```
**Flow:**
@@ -31,6 +35,10 @@ devtools release 1.10.3 --dry-run # preview without changes
5. Updates changelogs (en, pt-BR, ko) and docs version switcher
6. Creates docs PR against main, polls until merged
7. Tags main and creates GitHub release
8. Triggers PyPI publish workflow
9. Clones enterprise repo, bumps versions and `crewai[tools]` dep, runs `uv sync`
10. Creates enterprise bump PR, polls until merged
11. Tags and creates GitHub release on enterprise repo
### `devtools bump <version>`

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.12.0a2"
__version__ = "1.12.2"

View File

@@ -2,10 +2,13 @@
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
from typing import Final, Literal
from urllib.request import urlopen
import click
from dotenv import load_dotenv
@@ -153,12 +156,24 @@ def update_version_in_file(file_path: Path, new_version: str) -> bool:
return False
def update_pyproject_dependencies(file_path: Path, new_version: str) -> bool:
_DEFAULT_WORKSPACE_PACKAGES: Final[list[str]] = [
"crewai",
"crewai-tools",
"crewai-devtools",
]
def update_pyproject_dependencies(
file_path: Path,
new_version: str,
extra_packages: list[str] | None = None,
) -> bool:
"""Update workspace dependency versions in pyproject.toml.
Args:
file_path: Path to pyproject.toml file.
new_version: New version string.
extra_packages: Additional package names to update beyond the defaults.
Returns:
True if any dependencies were updated, False otherwise.
@@ -170,7 +185,7 @@ def update_pyproject_dependencies(file_path: Path, new_version: str) -> bool:
lines = content.splitlines()
updated = False
workspace_packages = ["crewai", "crewai-tools", "crewai-devtools"]
workspace_packages = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or [])
for i, line in enumerate(lines):
for pkg in workspace_packages:
@@ -431,12 +446,29 @@ def update_changelog(
return True
def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]:
"""Update crewai dependency versions in CLI template pyproject.toml files.
def _pin_crewai_deps(content: str, version: str) -> str:
"""Replace crewai dependency version pins in a pyproject.toml string.
Handles both pinned (==) and minimum (>=) version specifiers,
as well as extras like [tools].
Args:
content: File content to transform.
version: New version string.
Returns:
Transformed content.
"""
return re.sub(
r'"crewai(\[tools\])?(==|>=)[^"]*"',
lambda m: f'"crewai{(m.group(1) or "")!s}=={version}"',
content,
)
def update_template_dependencies(templates_dir: Path, new_version: str) -> list[Path]:
"""Update crewai dependency versions in CLI template pyproject.toml files.
Args:
templates_dir: Path to the CLI templates directory.
new_version: New version string.
@@ -444,16 +476,10 @@ def update_template_dependencies(templates_dir: Path, new_version: str) -> list[
Returns:
List of paths that were updated.
"""
import re
updated = []
for pyproject in templates_dir.rglob("pyproject.toml"):
content = pyproject.read_text()
new_content = re.sub(
r'"crewai(\[tools\])?(==|>=)[^"]*"',
lambda m: f'"crewai{(m.group(1) or "")!s}=={new_version}"',
content,
)
new_content = _pin_crewai_deps(content, new_version)
if new_content != content:
pyproject.write_text(new_content)
updated.append(pyproject)
@@ -607,24 +633,26 @@ def get_github_contributors(commit_range: str) -> list[str]:
# ---------------------------------------------------------------------------
def _poll_pr_until_merged(branch_name: str, label: str) -> None:
"""Poll a GitHub PR until it is merged. Exit if closed without merging."""
def _poll_pr_until_merged(
branch_name: str, label: str, repo: str | None = None
) -> None:
"""Poll a GitHub PR until it is merged. Exit if closed without merging.
Args:
branch_name: Branch name to look up the PR.
label: Human-readable label for status messages.
repo: Optional GitHub repo (owner/name) for cross-repo PRs.
"""
console.print(f"[cyan]Waiting for {label} to be merged...[/cyan]")
cmd = ["gh", "pr", "view", branch_name]
if repo:
cmd.extend(["--repo", repo])
cmd.extend(["--json", "state", "--jq", ".state"])
while True:
time.sleep(10)
try:
state = run_command(
[
"gh",
"pr",
"view",
branch_name,
"--json",
"state",
"--jq",
".state",
]
)
state = run_command(cmd)
except subprocess.CalledProcessError:
state = ""
@@ -984,8 +1012,252 @@ def _create_tag_and_release(
console.print(f"[green]✓[/green] Created GitHub {release_type} for {tag_name}")
def _trigger_pypi_publish(tag_name: str) -> None:
"""Trigger the PyPI publish GitHub Actions workflow."""
_ENTERPRISE_REPO: Final[str | None] = os.getenv("ENTERPRISE_REPO")
_ENTERPRISE_VERSION_DIRS: Final[tuple[str, ...]] = tuple(
d.strip() for d in os.getenv("ENTERPRISE_VERSION_DIRS", "").split(",") if d.strip()
)
_ENTERPRISE_CREWAI_DEP_PATH: Final[str | None] = os.getenv("ENTERPRISE_CREWAI_DEP_PATH")
_ENTERPRISE_EXTRA_PACKAGES: Final[tuple[str, ...]] = tuple(
p.strip()
for p in os.getenv("ENTERPRISE_EXTRA_PACKAGES", "").split(",")
if p.strip()
)
def _update_enterprise_crewai_dep(pyproject_path: Path, version: str) -> bool:
"""Update the crewai[tools] pin in an enterprise pyproject.toml.
Args:
pyproject_path: Path to the pyproject.toml file.
version: New crewai version string.
Returns:
True if the file was modified.
"""
if not pyproject_path.exists():
return False
content = pyproject_path.read_text()
new_content = _pin_crewai_deps(content, version)
if new_content != content:
pyproject_path.write_text(new_content)
return True
return False
_PYPI_POLL_INTERVAL: Final[int] = 15
_PYPI_POLL_TIMEOUT: Final[int] = 600
def _wait_for_pypi(package: str, version: str) -> None:
"""Poll PyPI until a specific package version is available.
Args:
package: PyPI package name.
version: Version string to wait for.
"""
url = f"https://pypi.org/pypi/{package}/{version}/json"
deadline = time.monotonic() + _PYPI_POLL_TIMEOUT
console.print(f"[cyan]Waiting for {package}=={version} to appear on PyPI...[/cyan]")
while time.monotonic() < deadline:
try:
with urlopen(url) as resp: # noqa: S310
if resp.status == 200:
console.print(
f"[green]✓[/green] {package}=={version} is available on PyPI"
)
return
except Exception: # noqa: S110
pass
time.sleep(_PYPI_POLL_INTERVAL)
console.print(
f"[red]Error:[/red] Timed out waiting for {package}=={version} on PyPI"
)
sys.exit(1)
def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None:
"""Clone the enterprise repo, bump versions, and create a release PR.
Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and
ENTERPRISE_CREWAI_DEP_PATH to be validated before calling.
Args:
version: New version string.
is_prerelease: Whether this is a pre-release version.
dry_run: Show what would be done without making changes.
"""
if (
not _ENTERPRISE_REPO
or not _ENTERPRISE_VERSION_DIRS
or not _ENTERPRISE_CREWAI_DEP_PATH
):
console.print("[red]Error:[/red] Enterprise env vars not configured")
sys.exit(1)
enterprise_repo: str = _ENTERPRISE_REPO
enterprise_dep_path: str = _ENTERPRISE_CREWAI_DEP_PATH
console.print(
f"\n[bold cyan]Phase 3: Releasing {enterprise_repo} {version}[/bold cyan]"
)
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(
f"[dim][DRY RUN][/dim] Would update crewai[tools] dep in "
f"{enterprise_dep_path}"
)
console.print(
"[dim][DRY RUN][/dim] Would create bump PR, wait for merge, "
"then tag and release"
)
return
with tempfile.TemporaryDirectory() as tmp:
repo_dir = Path(tmp) / enterprise_repo.split("/")[-1]
console.print(f"Cloning {enterprise_repo}...")
run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)])
console.print(f"[green]✓[/green] Cloned {enterprise_repo}")
# --- bump versions ---
for rel_dir in _ENTERPRISE_VERSION_DIRS:
pkg_dir = repo_dir / rel_dir
if not pkg_dir.exists():
console.print(
f"[yellow]Warning:[/yellow] {rel_dir} not found, skipping"
)
continue
for vfile in find_version_files(pkg_dir):
if update_version_in_file(vfile, version):
console.print(
f"[green]✓[/green] Updated: {vfile.relative_to(repo_dir)}"
)
pyproject = pkg_dir / "pyproject.toml"
if pyproject.exists():
if update_pyproject_dependencies(
pyproject, version, extra_packages=list(_ENTERPRISE_EXTRA_PACKAGES)
):
console.print(
f"[green]✓[/green] Updated deps in: "
f"{pyproject.relative_to(repo_dir)}"
)
# --- update crewai[tools] pin ---
enterprise_pyproject = repo_dir / enterprise_dep_path
if _update_enterprise_crewai_dep(enterprise_pyproject, version):
console.print(
f"[green]✓[/green] Updated crewai[tools] dep in {enterprise_dep_path}"
)
_wait_for_pypi("crewai", version)
console.print("\nSyncing workspace...")
run_command(["uv", "sync"], cwd=repo_dir)
console.print("[green]✓[/green] Workspace synced")
# --- branch, commit, push, PR ---
branch_name = f"feat/bump-version-{version}"
run_command(["git", "checkout", "-b", branch_name], cwd=repo_dir)
run_command(["git", "add", "."], cwd=repo_dir)
run_command(
["git", "commit", "-m", f"feat: bump versions to {version}"],
cwd=repo_dir,
)
console.print("[green]✓[/green] Changes committed")
run_command(["git", "push", "-u", "origin", branch_name], cwd=repo_dir)
console.print("[green]✓[/green] Branch pushed")
run_command(
[
"gh",
"pr",
"create",
"--repo",
enterprise_repo,
"--base",
"main",
"--title",
f"feat: bump versions to {version}",
"--body",
"",
],
cwd=repo_dir,
)
console.print("[green]✓[/green] Enterprise bump PR created")
_poll_pr_until_merged(branch_name, "enterprise bump PR", repo=enterprise_repo)
# --- tag and release ---
run_command(["git", "checkout", "main"], cwd=repo_dir)
run_command(["git", "pull"], cwd=repo_dir)
tag_name = version
run_command(
["git", "tag", "-a", tag_name, "-m", f"Release {version}"],
cwd=repo_dir,
)
run_command(["git", "push", "origin", tag_name], cwd=repo_dir)
console.print(f"[green]✓[/green] Pushed tag {tag_name}")
gh_cmd = [
"gh",
"release",
"create",
tag_name,
"--repo",
enterprise_repo,
"--title",
tag_name,
"--notes",
f"Release {version}",
]
if is_prerelease:
gh_cmd.append("--prerelease")
run_command(gh_cmd)
release_type = "prerelease" if is_prerelease else "release"
console.print(
f"[green]✓[/green] Created GitHub {release_type} for "
f"{enterprise_repo} {tag_name}"
)
def _trigger_pypi_publish(tag_name: str, wait: bool = False) -> None:
"""Trigger the PyPI publish GitHub Actions workflow.
Args:
tag_name: The release tag to publish.
wait: Block until the workflow run completes.
"""
# Capture the latest run ID before triggering so we can detect the new one
prev_run_id = ""
if wait:
try:
prev_run_id = run_command(
[
"gh",
"run",
"list",
"--workflow=publish.yml",
"--limit=1",
"--json=databaseId",
"--jq=.[0].databaseId",
]
)
except subprocess.CalledProcessError:
console.print(
"[yellow]Note:[/yellow] Could not determine previous workflow run; "
"continuing without previous run ID"
)
with console.status("[cyan]Triggering PyPI publish workflow..."):
try:
run_command(
@@ -1003,6 +1275,42 @@ def _trigger_pypi_publish(tag_name: str) -> None:
sys.exit(1)
console.print("[green]✓[/green] Triggered PyPI publish workflow")
if wait:
console.print("[cyan]Waiting for PyPI publish workflow to complete...[/cyan]")
run_id = ""
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
time.sleep(5)
try:
run_id = run_command(
[
"gh",
"run",
"list",
"--workflow=publish.yml",
"--limit=1",
"--json=databaseId",
"--jq=.[0].databaseId",
]
)
except subprocess.CalledProcessError:
continue
if run_id and run_id != prev_run_id:
break
if not run_id or run_id == prev_run_id:
console.print(
"[red]Error:[/red] Could not find the PyPI publish workflow run"
)
sys.exit(1)
try:
run_command(["gh", "run", "watch", run_id, "--exit-status"])
except subprocess.CalledProcessError as e:
console.print(f"[red]✗[/red] PyPI publish workflow failed: {e}")
sys.exit(1)
console.print("[green]✓[/green] PyPI publish workflow completed")
# ---------------------------------------------------------------------------
# CLI commands
@@ -1032,6 +1340,15 @@ def bump(version: str, dry_run: bool, no_push: bool, no_commit: bool) -> None:
no_push: Don't push changes to remote.
no_commit: Don't commit changes (just update files).
"""
console.print(
f"\n[yellow]Note:[/yellow] [bold]devtools bump[/bold] only bumps versions "
f"in this repo. It will not tag, publish to PyPI, or release enterprise.\n"
f"If you want a full end-to-end release, run "
f"[bold]devtools release {version}[/bold] instead."
)
if not Confirm.ask("Continue with bump only?", default=True):
sys.exit(0)
try:
check_gh_installed()
@@ -1136,6 +1453,16 @@ def tag(dry_run: bool, no_edit: bool) -> None:
dry_run: Show what would be done without making changes.
no_edit: Skip editing release notes.
"""
console.print(
"\n[yellow]Note:[/yellow] [bold]devtools tag[/bold] only tags and creates "
"a GitHub release for this repo. It will not bump versions, publish to "
"PyPI, or release enterprise.\n"
"If you want a full end-to-end release, run "
"[bold]devtools release <version>[/bold] instead."
)
if not Confirm.ask("Continue with tag only?", default=True):
sys.exit(0)
try:
cwd = Path.cwd()
lib_dir = cwd / "lib"
@@ -1226,21 +1553,44 @@ def tag(dry_run: bool, no_edit: bool) -> None:
"--dry-run", is_flag=True, help="Show what would be done without making changes"
)
@click.option("--no-edit", is_flag=True, help="Skip editing release notes")
def release(version: str, dry_run: bool, no_edit: bool) -> None:
@click.option(
"--skip-enterprise",
is_flag=True,
help="Skip the enterprise release phase",
)
def release(version: str, dry_run: bool, no_edit: bool, skip_enterprise: bool) -> None:
"""Full release: bump versions, tag, and publish a GitHub release.
Combines bump and tag into a single workflow. Creates a version bump PR,
waits for it to be merged, then generates release notes, updates docs,
creates the tag, and publishes a GitHub release.
creates the tag, and publishes a GitHub release. Then bumps versions and
releases the enterprise repo.
Args:
version: New version to set (e.g., 1.0.0, 1.0.0a1).
dry_run: Show what would be done without making changes.
no_edit: Skip editing release notes.
skip_enterprise: Skip the enterprise release phase.
"""
try:
check_gh_installed()
if not skip_enterprise:
missing: list[str] = []
if not _ENTERPRISE_REPO:
missing.append("ENTERPRISE_REPO")
if not _ENTERPRISE_VERSION_DIRS:
missing.append("ENTERPRISE_VERSION_DIRS")
if not _ENTERPRISE_CREWAI_DEP_PATH:
missing.append("ENTERPRISE_CREWAI_DEP_PATH")
if missing:
console.print(
f"[red]Error:[/red] Missing required environment variable(s): "
f"{', '.join(missing)}\n"
f"Set them or pass --skip-enterprise to skip the enterprise release."
)
sys.exit(1)
cwd = Path.cwd()
lib_dir = cwd / "lib"
@@ -1337,7 +1687,10 @@ def release(version: str, dry_run: bool, no_edit: bool) -> None:
if not dry_run:
_create_tag_and_release(tag_name, release_notes, is_prerelease)
_trigger_pypi_publish(tag_name)
_trigger_pypi_publish(tag_name, wait=not skip_enterprise)
if not skip_enterprise:
_release_enterprise(version, is_prerelease, dry_run)
console.print(f"\n[green]✓[/green] Release [bold]{version}[/bold] complete!")